@okyrychenko-dev/react-zustand-toolkit
v0.4.1
Published
Type-safe Zustand helpers for shallow-first selectors, isolated providers, and resolved store hooks
Maintainers
Readme
@okyrychenko-dev/react-zustand-toolkit
Type-safe Zustand helpers for shallow-first selectors, isolated providers, and hooks that resolve between global and scoped stores.
What This Library Does
react-zustand-toolkit gives you three composable layers:
createShallowStorefor a global singleton store with shallow-first selectorscreateStoreProviderfor isolated store instances in React contextcreateStoreToolkitfor both patterns together, plus resolved hooks that work inside and outside a provider
It does not ship its own DevTools runtime for providers.
If you want Zustand Redux DevTools, apply devtools(...) in the store creator itself.
Features
- Shallow-first selectors with explicit plain-selector hooks
- Context providers with isolated store instances
- Resolved hooks that choose context store first and fall back to global store
- Optional custom equality for shallow-first selector hooks
- Full TypeScript inference with Zustand middleware support
- React 19 helpers for transitions, optimistic updates, and action state adapters
Installation
npm install @okyrychenko-dev/react-zustand-toolkit zustandQuick Start
import { createStoreToolkit } from "@okyrychenko-dev/react-zustand-toolkit";
interface CounterStore {
count: number;
increment: () => void;
decrement: () => void;
}
const counterToolkit = createStoreToolkit<CounterStore>(
(set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
}),
{ name: "Counter" }
);
export const {
useStore: useCounterStore,
useResolvedValue: useCounter,
useResolvedStoreApi: useCounterStoreApi,
} = counterToolkit;
export const { Provider: CounterProvider } = counterToolkit.provider;
function Counter() {
const count = useCounter((state) => state.count);
const increment = useCounter((state) => state.increment);
return <button onClick={increment}>Count: {count}</button>;
}
function App() {
return (
<CounterProvider>
<Counter />
</CounterProvider>
);
}Which Factory To Use
createShallowStore
Use this when you want a global singleton store.
import { createShallowStore } from "@okyrychenko-dev/react-zustand-toolkit";
interface SessionStore {
token: string | null;
setToken: (token: string | null) => void;
}
const { useStore, useStorePlain, useStoreApi } = createShallowStore<SessionStore>((set) => ({
token: null,
setToken: (token) => set({ token }),
}));
const token = useStore((state) => state.token);
const plainToken = useStorePlain((state) => state.token);
const storeApi = useStoreApi;Returns:
useStoreuseStorePlainuseStoreApi
createStoreProvider
Use this when every provider instance must own a separate store.
import { createStoreProvider } from "@okyrychenko-dev/react-zustand-toolkit";
interface WizardStore {
step: number;
next: () => void;
}
const { Provider: WizardProvider, useContextStore, useContextStoreApi } =
createStoreProvider<WizardStore>((set) => ({
step: 1,
next: () => set((state) => ({ step: state.step + 1 })),
}), "Wizard");
function WizardStep() {
const step = useContextStore((state) => state.step);
return <div>Step {step}</div>;
}
function WizardShell() {
return (
<WizardProvider>
<WizardStep />
</WizardProvider>
);
}Returns:
ProvideruseContextStoreApiuseContextStoreuseContextStorePlainuseContextStoreOptionaluseIsInsideProvider
createStoreToolkit
Use this when components should work both with and without a provider.
import { createStoreToolkit } from "@okyrychenko-dev/react-zustand-toolkit";
interface CartStore {
items: string[];
addItem: (item: string) => void;
}
const cartToolkit = createStoreToolkit<CartStore>((set) => ({
items: [],
addItem: (item) => set((state) => ({ items: [...state.items, item] })),
}), { name: "Cart" });
export const { useResolvedValue: useCart } = cartToolkit;
export const { Provider: CartProvider } = cartToolkit.provider;
function CartCount() {
const items = useCart((state) => state.items);
return <span>{items.length}</span>;
}Returns:
useStoreuseStorePlainuseStoreApiprovidergetProvider()createProvider()deprecated aliasuseResolvedStoreApi()useResolvedValue()useResolvedStorePlain()
Selector Semantics
Shallow-first mode
useStore, useContextStore, and useResolvedValue keep the previous selected value when the equality check passes.
By default they use zustand/shallow.
This is useful for object and array picks:
const selection = useCounter((state) => ({
count: state.count,
increment: state.increment,
}));You can also provide your own equality function:
const stableUser = useCounter(
(state) => state.user,
(left, right) => left?.id === right?.id
);Plain mode
If you want standard Zustand selector behavior, use the explicit plain hooks:
const value = useStorePlain((state) => state.value);
const contextValue = useContextStorePlain((state) => state.value);
const resolvedValue = useResolvedStorePlain((state) => state.value);Resolved Hooks
Resolved hooks prefer the provider store when the component is inside a matching provider. Otherwise they fall back to the global store.
const toolkit = createStoreToolkit<MyStore>((set) => ({
value: 0,
increment: () => set((state) => ({ value: state.value + 1 })),
}));
const { useResolvedValue, useResolvedStoreApi } = toolkit;
function Status() {
const value = useResolvedValue((state) => state.value);
const store = useResolvedStoreApi();
return <button onClick={() => store.getState().increment()}>{value}</button>;
}Provider Lifecycle
createStoreProvider supports two lifecycle stages:
onStoreInitfor synchronous initialization during store creationonStoreReadyfor post-commit side effects
const { Provider } = createStoreProvider<AppStore>((set) => ({
ready: false,
setReady: (ready: boolean) => set({ ready }),
}));
<Provider
onStoreInit={(store) => {
store.getState().setReady(true);
}}
onStoreReady={(store) => {
console.log("store mounted", store.getState());
}}
>
<App />
</Provider>;Deprecated alias:
onStoreCreatemaps to the post-commitonStoreReadybehavior
Middleware Support
Zustand middleware belongs in the store creator. That includes Redux DevTools support.
import { createShallowStore } from "@okyrychenko-dev/react-zustand-toolkit";
import { devtools, persist } from "zustand/middleware";
interface CounterStore {
count: number;
increment: () => void;
}
const { useStore, useStoreApi } = createShallowStore<
CounterStore,
[["zustand/persist", CounterStore], ["zustand/devtools", never]]
>(
persist(
devtools(
(set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
}),
{ name: "CounterStore" }
),
{ name: "counter-store" }
)
);
useStoreApi.persist.rehydrate();
useStoreApi.devtools.cleanup();This library does not auto-connect provider instances to Redux DevTools.
TypeScript
The toolkit is designed to preserve store API types when you use Zustand middleware.
import { createShallowStore } from "@okyrychenko-dev/react-zustand-toolkit";
import { subscribeWithSelector } from "zustand/middleware";
interface FilterStore {
query: string;
setQuery: (query: string) => void;
}
const { useStoreApi } = createShallowStore<
FilterStore,
[["zustand/subscribeWithSelector", never]]
>(
subscribeWithSelector((set) => ({
query: "",
setQuery: (query) => set({ query }),
}))
);
const unsubscribe = useStoreApi.subscribe(
(state) => state.query,
(nextQuery) => {
console.log(nextQuery);
}
);
unsubscribe();Migration
Deprecated names still exist for compatibility, but new code should prefer the current API.
Provider access
const { Provider } = toolkit.provider;Old aliases:
const { Provider } = toolkit.getProvider();
const { Provider: LegacyProvider } = toolkit.createProvider();Raw provider hooks
const store = useContextStoreApi();
const maybeStore = useContextStoreOptional();Old aliases:
const store = useContext();
const maybeStore = useOptionalContext();Resolved hooks
const value = useResolvedValue((state) => state.value);
const store = useResolvedStoreApi();Old aliases:
const value = useResolvedStoreWithSelector((state) => state.value);
const store = useResolvedStore();React 19 Helpers
import {
createTransitionAction,
useActionStateAdapter,
useOptimisticReducer,
} from "@okyrychenko-dev/react-zustand-toolkit";
const incrementInTransition = createTransitionAction(() => {
counterToolkit.useStoreApi.getState().increment();
});
const [status, submit, isPending] = useActionStateAdapter(async (payload: FormData) => {
await save(payload);
return "saved";
}, "idle");
const [optimisticTodos, addOptimisticTodo] = useOptimisticReducer(
todos,
(current, nextTodo) => [...current, nextTodo]
);Development
npm install
npm run typecheck
npm run test:run
npm run buildLicense
MIT © Oleksii Kyrychenko
