Zustand is one of the rare state management libraries that feels good almost immediately. It is small, fast, and does not try to force a framework-sized architecture onto your app.
That simplicity is exactly why many teams adopt it quickly.
Then the app grows, and a different problem shows up: scoped state.
What happens when your app needs multiple, isolated instances of the same store? Imagine a dashboard where each complex "widget" needs its own independent state or a multi-step "wizard" where simultaneous tabs shouldn't overwrite each other's data.
The official Zustand documentation recommends using React Context for this, but doing it manually is a grind. You have to:
- Create a React Context.
- Create a factory function for the store instance.
- Build a wrapper Provider component.
- Manually rebuild strongly-typed selector hooks (
useStore,useStoreApi) for consumers. - Pepper your codebase with
useShallowto prevent unnecessary re-renders when returning objects or arrays.
At that point, plain Zustand is still capable, but the implementation starts getting repetitive.
To reduce that boilerplate, I built @okyrychenko-dev/react-zustand-toolkit.
It gives you a few composable helpers around Zustand:
- generated context providers
- shallow-first selectors by default
- "resolved" hooks that can read from either a scoped or global store
- a small set of React 19 utilities
The goal of this article is not to oversell that toolkit. It is to show the real architectural cases where it helps, where it does not, and how its three main factory functions map to actual React state ownership patterns.
Before We Start: The Real Problem
Zustand itself is not the problem. In many apps, plain Zustand is already enough:
- one global store
- a few focused selectors
- occasional middleware
- no need for isolated store instances
The pain starts when your architecture stops being purely global.
That usually happens in one of these situations:
- you render the same complex widget multiple times and each instance needs separate state
- you build reusable modules that should work standalone and also inside a larger application
- you want most of the app to read from one global store, but a subtree should temporarily override it
- you are tired of repeating provider + context + hook wiring for every isolated Zustand use case
That is the exact gap this toolkit is trying to cover.
So while this article shows the library API, the more important takeaway is architectural:
- use a plain global store when isolation is not needed
- use scoped providers when identity and lifetime matter per subtree
- use resolved hooks when consumers should not care where the state comes from
With that framing in place, the API makes much more sense.
1. The Global Singleton: createShallowStore
Letβs start with the simplest layer.
If you are just building a standard global store, the main reason to use this layer is shallow-first selectors.
In standard Zustand, if your selector returns a new object or array, your component will re-render every single time the store updates, even if the selected values haven't changed. To fix this, you have to manually wrap your selectors:
// β Standard Zustand requires boilerplate for shallow picks
import { useShallow } from 'zustand/react/shallow'
const { id, name } = useUserStore(
useShallow((state) => ({ id: state.id, name: state.name }))
)
With createShallowStore, your generated hooks use zustand/shallow by default. You can pick objects and arrays freely without the boilerplate:
import { createShallowStore } from "@okyrychenko-dev/react-zustand-toolkit";
interface SessionStore {
token: string | null;
user: { name: string; role: string } | null;
login: (token: string, user: { name: string; role: string }) => void;
}
const { useStore, useStorePlain, useStoreApi } = createShallowStore<SessionStore>((set) => ({
token: null,
user: null,
login: (token, user) => set({ token, user }),
}));
// β
Object picks use shallow comparison by default.
function ProfileInfo() {
const { token, user } = useStore((state) => ({
token: state.token,
user: state.user
}));
return <div>{user?.name}</div>;
}
If you ever need standard, strict-equality behavior, the toolkit always provides explicit useStorePlain alternatives.
Why this matters in practice
The shallow-first approach is especially useful when components naturally want to read small object bundles:
const { isLoading, error, reload } = useStore((state) => ({
isLoading: state.isLoading,
error: state.error,
reload: state.reload,
}));
In plain Zustand, patterns like this often push teams into one of two habits:
- wrapping selectors in
useShallow - splitting every field into its own selector call
Both work. They are just noisy when repeated across a large codebase.
This helper does not replace selector discipline. It simply makes the common "pick a few fields" case less repetitive.
What it does not do
It is still important to be precise about the limits:
- it does not make every selector free
- it does not replace good store design
- it does not solve deep comparison problems
- it does not remove the need to think about derived data and subscription granularity
It mainly improves the ergonomics of shallow object and array picks.
2. Isolated Store Contexts: createStoreProvider
The next layer is where Zustand usually becomes a little more manual.
When you need true isolation, where every instance of a component must own a separate store, createStoreProvider removes most of the repetitive setup.
It generates the Context, the Provider component, and the typed consumer hooks in a single call.
import { createStoreProvider } from "@okyrychenko-dev/react-zustand-toolkit";
interface WizardStore {
step: number;
direction: 'forward' | 'backward';
next: () => void;
}
// 1. Generate the provider and hooks
export const {
Provider: WizardProvider,
useContextStore,
useContextStoreApi
} = createStoreProvider<WizardStore>((set) => ({
step: 1,
direction: 'forward',
next: () => set((state) => ({
step: state.step + 1,
direction: 'forward'
})),
}), "Wizard");
// 2. Consume safely within the isolated tree
function WizardControls() {
const step = useContextStore((state) => state.step);
const next = useContextStore((state) => state.next);
return (
<div>
<p>Current Step: {step}</p>
<button onClick={next}>Next Step</button>
</div>
);
}
Why provider-scoped Zustand is useful
Context-scoped stores are not just an implementation detail. They model a different ownership pattern.
With a global singleton store:
- the store exists once
- every consumer shares the same data
- state lifetime usually matches the application lifetime
With a provider-scoped store:
- each provider instance owns one store
- sibling subtrees can hold completely different values
- state lifetime follows the mounted subtree
That makes provider-scoped stores a good fit for:
- wizards
- modals with complex internal state
- embeddable widgets
- repeated dashboard panels
- request or test isolation
Provider Lifecycle Hooks
Sometimes you need to initialize your isolated store with data from outside (like props) before the component renders, or run a side effect right after it mounts.
The generated Provider component supports two lifecycle stages:
-
onStoreInit: Synchronous initialization during store creation. -
onStoreReady: Post-commit side effects.
function WizardShell({ initialStep }: { initialStep: number }) {
return (
<WizardProvider
onStoreInit={(store) => {
// Initialize the store synchronously before first render
store.setState({ step: initialStep });
}}
onStoreReady={(store) => {
// Run side effects like analytics tracking after mount
console.log("Wizard instance mounted at step", store.getState().step);
}}
>
<WizardControls />
</WizardProvider>
);
}
That split is small, but useful:
-
onStoreInitis for deterministic setup before consumers read the store -
onStoreReadyis for effects that should happen after mount
That is a better mental model than mixing initialization and side effects in the same callback.
3. The Best of Both Worlds: createStoreToolkit
This is the layer that makes the package feel more like a toolkit and less like a single helper.
What if you have global state, but certain parts of the UI need to override it locally?
This is where createStoreToolkit becomes useful.
It creates both a global singleton store and an optional context provider. It also gives you resolved hooks such as useResolvedValue and useResolvedStoreApi.
These hooks dynamically check the React Component tree:
- Are we inside a Provider for this store? If yes, use the scoped context store.
- No Provider found? Fall back to the global singleton store.
Take a look at this Theme example:
import { createStoreToolkit } from "@okyrychenko-dev/react-zustand-toolkit";
interface ThemeStore {
mode: 'light' | 'dark';
setMode: (mode: 'light' | 'dark') => void;
}
// Generates both global store AND provider
const themeToolkit = createStoreToolkit<ThemeStore>((set) => ({
mode: 'light', // Global default
setMode: (mode) => set({ mode }),
}), { name: "Theme" });
export const { useResolvedValue: useTheme } = themeToolkit;
export const { Provider: ThemeProvider } = themeToolkit.provider;
Now consuming components do not need to care whether they are reading from the global store or a scoped provider instance:
function ThemedCard() {
const mode = useTheme((state) => state.mode);
return <div className={`card-${mode}`}>Smart Card</div>;
}
function App() {
return (
<div>
{/* π 1. Uses the global 'light' theme */}
<ThemedCard />
{/* π 2. Overrides the state to 'dark' for this specific tree ONLY */}
<ThemeProvider onStoreInit={(store) => store.getState().setMode('dark')}>
<div className="dark-zone">
<ThemedCard />
</div>
</ThemeProvider>
</div>
);
}
This hybrid pattern is useful for reusable UI modules, nested widgets, or apps where most of the UI can share one store, but a subtree sometimes needs an isolated instance.
Why resolved hooks are interesting
This is probably the most opinionated part of the library.
Normally, when a component can run in two modes, you end up with one of these designs:
- separate hooks for global and scoped usage
- props that inject the store
- branching logic scattered across the component tree
Resolved hooks collapse that decision into one place:
- inside the matching provider, read the scoped store
- outside it, read the global store
That can simplify component APIs a lot, especially in shared UI packages.
A good mental model
Think of createStoreToolkit as:
- a normal global Zustand store
- plus an optional scoped override mechanism
- plus consumer hooks that pick the nearest valid source
That framing is more accurate than thinking of it as "magic context Zustand".
Where to be careful
Resolved hooks are convenient, but they are also a design choice. I would avoid them when:
- the distinction between global and local state should be explicit in the component API
- debugging would become ambiguous because a component may silently switch data sources
- different teams own global and scoped behavior separately
In other words, resolved hooks are best when the fallback behavior is intentional, not surprising.
4. Middleware Without Losing Types
So far the value has been architectural. This section is more about preserving the normal Zustand experience.
Zustand middleware such as Redux DevTools, Persist, or SubscribeWithSelector still belongs in the store creator.
The useful part here is that the toolkit preserves the resulting store API types, so helpers like persist.rehydrate or selector-aware subscribe remain available on useStoreApi.
import { createShallowStore } from "@okyrychenko-dev/react-zustand-toolkit";
import { devtools, persist } from "zustand/middleware";
interface CartStore {
items: string[];
addItem: (item: string) => void;
}
// Middleware types are preserved on the store API.
const { useStore, useStoreApi } = createShallowStore<
CartStore,
[["zustand/persist", CartStore], ["zustand/devtools", never]]
>(
persist(
devtools(
(set) => ({
items: [],
addItem: (item) => set((state) => ({ items: [...state.items, item] })),
}),
{ name: "GlobalCartStore" }
),
{ name: "cart-storage" }
)
);
// Mutator APIs stay typed.
useStoreApi.persist.rehydrate();
useStoreApi.devtools.cleanUp();
This does not mean the toolkit adds a custom DevTools layer for provider stores. If you want Redux DevTools, apply Zustand middleware in the creator itself. Dynamic provider instances are not auto-connected for you.
This is a subtle point, but an important one.
The library is not trying to compete with Zustand middleware. It is trying to stay out of the way while preserving the resulting types.
That is the right design choice. Middleware remains a Zustand concern, not a toolkit-specific abstraction.
5. Ready for React 19 βοΈ
This part is useful, but it should be read with the right expectations.
React 19 introduces hooks and rendering primitives such as Transitions, Action State, and Optimistic Updates.
@okyrychenko-dev/react-zustand-toolkit includes a few small utilities around those APIs. They are wrappers, not a new state model.
Wrapping Actions in Transitions
If you have an update that may trigger expensive rendering, you can wrap the action in a transition:
import { createTransitionAction } from "@okyrychenko-dev/react-zustand-toolkit";
const incrementInTransition = createTransitionAction(() => {
// This update runs inside React.startTransition
counterToolkit.useStoreApi.getState().increment();
});
Action State Adapters
If you want a thin adapter over useActionState for store-related async actions:
import { useActionStateAdapter } from "@okyrychenko-dev/react-zustand-toolkit";
function SaveForm() {
const [status, submitForm, isPending] = useActionStateAdapter(
async (payload: FormData) => {
await myApi.save(payload);
myStore.getState().markSaved();
return "saved";
},
"idle"
);
return (
<form action={submitForm}>
<button disabled={isPending}>
{isPending ? "Saving..." : "Save"}
</button>
{status === 'saved' && <p>Saved successfully!</p>}
</form>
);
}
Optimistic UI Updates
If you want an optimistic layer on top of committed Zustand state:
import { useOptimisticReducer } from "@okyrychenko-dev/react-zustand-toolkit";
function TodoList() {
const serverTodos = useTodos((state) => state.todos);
const [optimisticTodos, addOptimisticTodo] = useOptimisticReducer(
serverTodos,
(current, nextTodo) => [...current, nextTodo]
);
// ... render optimisticTodos instead of serverTodos
}
I would treat these helpers as convenience utilities, not the center of the package.
They are nice because they keep React 19-oriented code close to the same toolkit, but the core value of the library is still:
- store scoping
- shallow-first selectors
- resolved hooks
That is where the architectural leverage really is.
6. Which Factory Should You Reach For?
If you only remember one section from this article, make it this one.
If you are evaluating the library quickly, this is the practical decision tree:
Use createShallowStore when:
- you want one global singleton store
- your main annoyance is repeated
useShallowusage - you do not need isolated instances
Use createStoreProvider when:
- every mounted subtree should own its own store
- the state lifetime should end when that subtree unmounts
- store isolation should be explicit
Use createStoreToolkit when:
- you want a global store by default
- some subtrees should be able to override it with local instances
- your consumers should work in both environments with the same hook API
That separation is one of the better aspects of the package. The API is not trying to force one pattern onto every use case.
Quick Comparison
| Factory | Best for | Store lifetime | Main benefit |
|---|---|---|---|
createShallowStore |
One global store | App-wide | Shallow-first selectors with low boilerplate |
createStoreProvider |
Isolated subtree state | Per provider instance | Explicit store ownership and lifecycle |
createStoreToolkit |
Mixed global + local override scenarios | Global plus optional scoped instances | Shared consumer API through resolved hooks |
7. When You Probably Do Not Need This Library
This section matters because a good abstraction should come with a clear boundary.
It is also worth being explicit about the non-use-cases.
You probably do not need this toolkit if:
- your app already works well with a single global Zustand store
- you rarely select object or array bundles
- you do not use scoped providers at all
- you prefer explicit store injection over fallback resolution
There is no benefit in adding an abstraction layer just because it exists.
Good Zustand architecture is still mostly about picking the right ownership model for state. This toolkit simply makes a few of those models easier to implement consistently.
Wrapping Up
The strongest part of react-zustand-toolkit is not that it reinvents Zustand. It does not.
Its value is that it packages a few repeatable patterns into a small API:
- generated providers and hooks for isolated store instances
- shallow-first selector hooks with explicit plain alternatives
- resolved hooks for code that should work both inside and outside a provider
- typed passthrough for Zustand middleware
- a few optional React 19 wrappers
If those are problems you keep solving by hand, the library is worth a look.
If your app only needs a single global store, plain Zustand may still be enough, and that is completely fine.
But if your real problem is no longer "how do I store state?" and has become "who owns this state, how many instances of it exist, and how should components resolve it?", then this toolkit starts to become much more interesting.
Next Steps
Install it today:
npm install @okyrychenko-dev/react-zustand-toolkit zustand
Check out the full API reference, examples, and source code in the GitHub Repository.
If you have run into the "global store everywhere, until one subtree needs isolation" problem, this is the part of Zustand architecture the toolkit is trying to simplify.
Top comments (0)