npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@okyrychenko-dev/react-zustand-toolkit

v1.0.0

Published

Type-safe Zustand helpers for shallow-first selectors, isolated providers, and resolved store hooks

Downloads

1,202

Readme

@okyrychenko-dev/react-zustand-toolkit

npm version npm downloads License: MIT

Type-safe Zustand helpers for declarative Provider scope, shallow-first selection, SSR isolation, and middleware-preserving Store handles.

What this library does

react-zustand-toolkit provides four composable layers:

  • createStoreToolkit for the normal declarative path: Resolved selection, explicit Global access, and isolated Provider stores from one typed recipe
  • createShallowStore for a standalone process-wide Store with Shallow-first selectors
  • createStoreProvider for standalone isolated Provider stores
  • createResolvedStoreHooks for advanced composition of an existing Global store and Provider

The library does not ship its own Redux DevTools or persistence runtime. Apply Zustand middleware in the Store creator returned by the recipe.

Features

  • Declarative nearest-Provider resolution with Global fallback
  • Shallow-first and Plain selector modes, plus custom equality
  • Typed creation-only Provider input for deterministic initialization
  • Request-local SSR isolation and matching-input hydration
  • Stable imperative Store handles with Zustand middleware capabilities
  • Standalone factories for advanced composition

Installation

pnpm add @okyrychenko-dev/react-zustand-toolkit react zustand

The package supports React 18 or 19 and Zustand 5. It ships ESM and CommonJS entry points with TypeScript declarations.

Declarative toolkit

Provider placement declares which Store descendants observe. The top-level hooks use the nearest matching Provider store and otherwise fall back to the process-scoped Global store.

import { createStoreToolkit } from "@okyrychenko-dev/react-zustand-toolkit";

interface CounterInput {
  initialCount: number;
}

interface CounterStore {
  count: number;
  increment: () => void;
}

export const counter = createStoreToolkit<CounterStore, CounterInput>(
  ({ initialCount }) => (set) => ({
    count: initialCount,
    increment: () => set((state) => ({ count: state.count + 1 })),
  }),
  { globalInput: { initialCount: 0 }, name: "Counter" }
);

function Count() {
  const count = counter.useStore((state) => state.count);
  return <span>{count}</span>;
}

function App() {
  return (
    <counter.Provider input={{ initialCount: 10 }}>
      <Count />
    </counter.Provider>
  );
}

Outside a Provider, Count observes counter.global.store. Inside the Provider it observes 10. A nested counter.Provider wins for its descendants. Each Provider owns an isolated Store.

Toolkit interface

| Member | Behavior | | --- | --- | | Provider | Creates one isolated Provider store from required, inert input | | useStore | Resolved Shallow selection; accepts optional custom equality | | useStorePlain | Resolved selection with Zustand's default equality | | useStoreApi() | Hook returning the Resolved Store handle | | global.useStore | Explicit Global Shallow selection | | global.useStorePlain | Explicit Global Plain selection | | global.store | Stable imperative Global Store handle; not a hook |

The Store recipe runs synchronously when a Store is created and must be deterministic for equivalent input and free of external side effects. It constructs state, actions, and middleware together; the toolkit does not patch initial state afterward. The Global store is created immediately from globalInput; each Provider store is created on that Provider's first render. Later Provider input changes are ignored. Use Store actions for live changes or change the Provider key to start a new Store lifetime.

onStoreInit runs synchronously after construction and before descendants observe the Store. onStoreReady runs after commit at most once for a Provider Store. Put subscriptions, analytics, registrations, and other external effects in onStoreReady, not in the recipe or onStoreInit.

To model optional initialization, include undefined in the input type and still pass the required input prop.

Choosing a factory

createStoreToolkit

Use the declarative toolkit for most applications. Components use one top-level selection vocabulary and Provider placement determines whether they observe a local Store or the Global fallback. The complete example above shows its interface.

createShallowStore

Use this when a process-wide Store is intentional and Provider isolation is unnecessary.

import { createShallowStore } from "@okyrychenko-dev/react-zustand-toolkit";

interface SessionStore {
  token: string | null;
  setToken: (token: string | null) => void;
}

const session = createShallowStore<SessionStore>((set) => ({
  token: null,
  setToken: (token) => set({ token }),
}));

const token = session.useStore((state) => state.token);
const plainToken = session.useStorePlain((state) => state.token);
session.store.getState().setToken("token");

It returns useStore, useStorePlain, and the stable imperative store property.

createStoreProvider

Use this advanced factory when every Provider must own an isolated Store but you do not want Global fallback or the combined toolkit interface.

import { createStoreProvider } from "@okyrychenko-dev/react-zustand-toolkit";

interface WizardInput {
  initialStep: number;
}

interface WizardStore {
  step: number;
  next: () => void;
}

const wizard = createStoreProvider<WizardStore, WizardInput>(
  ({ initialStep }) => (set) => ({
    step: initialStep,
    next: () => set((state) => ({ step: state.step + 1 })),
  }),
  "Wizard"
);

function WizardStep() {
  const step = wizard.useContextStore((state) => state.step);
  return <div>Step {step}</div>;
}

function WizardShell() {
  return (
    <wizard.Provider input={{ initialStep: 1 }}>
      <WizardStep />
    </wizard.Provider>
  );
}

It returns Provider, strict Store hooks, useIsInsideProvider, and the advanced useProviderStoreOptional lookup. Strict hooks throw outside the matching Provider.

Provider lifecycle

Both the toolkit Provider and standalone Provider support two lifecycle stages:

  • onStoreInit runs synchronously once during Store creation, before descendants observe it.
  • onStoreReady runs after commit, at most once for that Store lifetime.

Use the Store recipe and onStoreInit only for synchronous construction and validation. Start subscriptions, analytics, registrations, and other external effects in onStoreReady. Changing callback identities does not repeat a lifecycle stage that has already completed. If onStoreReady is omitted initially and supplied on a later render, it runs once after that render commits.

Server rendering and hydration

Never put request-specific server data in global.store. The Global store is module-scoped and can be shared by concurrent requests. A request-local Provider is the SSR isolation boundary.

Decode transport data before passing it to the Provider. Serialize that same data into the response and give the client Provider observably equivalent decoded input on its first render.

interface PageInput {
  userId: string;
}

const page = createStoreToolkit<PageStore, PageInput>(
  ({ userId }) => () => ({ userId }),
  { globalInput: { userId: "anonymous" }, name: "Page" }
);

function PageView() {
  return <main>{page.useStore((state) => state.userId)}</main>;
}

// Server: create one element tree per request.
const input = decodePageInput(request);
const html = renderToString(
  <page.Provider input={input}>
    <PageView />
  </page.Provider>
);
const serializedInput = JSON.stringify(input).replaceAll("<", "\\u003c");

// Client: decode the embedded data and hydrate with equivalent input.
const clientInput = decodeEmbeddedPageInput(serializedInput);
const root = document.getElementById("root");
if (root) {
  hydrateRoot(
    root,
    <page.Provider input={clientInput}>
      <PageView />
    </page.Provider>
  );
}

The application owns encoding, decoding, and validation. React may construct and discard unobserved Stores while retrying server work; the guarantee is that distinct request trees do not share an observable Provider store, not an exact recipe call count.

Next.js App Router

Create the toolkit in a client module containing "use client". A React Server Component may load, validate, and serialize data, then pass inert props to a client component that renders the Provider. Server Components must not invoke toolkit hooks or read or mutate toolkit Store handles. No Next.js runtime dependency is required.

Persistence

Browser storage can change the first client state before hydration and cause markup mismatches. When using Zustand persistence, disable automatic hydration when needed, render from the server input, then rehydrate storage in a controlled post-commit step. Do not let persisted browser state replace the initial Provider state before React hydrates matching server markup.

Advanced standalone composition

The standalone factories remain available when custom wiring is genuinely required:

const global = createShallowStore(recipe({ initialCount: 0 }));
const provider = createStoreProvider<CounterStore, CounterInput>(recipe, "Counter");
const resolved = createResolvedStoreHooks(global.store, provider.useProviderStoreOptional);

createStoreProvider also exposes strict useContextStoreApi, Shallow useContextStore, Plain useContextStorePlain, useIsInsideProvider, and advanced useProviderStoreOptional. Strict hooks throw outside the matching Provider. Middleware-enhanced Store capabilities remain on all Store handles. createResolvedStoreHooks returns useResolvedStoreApi, Shallow useResolvedValue, and Plain useResolvedStorePlain for custom integrations.

Migration to 1.0

| Previous member | Replacement | | --- | --- | | toolkit.provider.Provider | toolkit.Provider | | toolkit.getProvider().Provider | toolkit.Provider | | toolkit.provider.useContextStore | toolkit.useStore for Resolved selection; createStoreProvider().useContextStore for standalone strict selection | | toolkit.provider.useContextStorePlain | toolkit.useStorePlain for Resolved selection; createStoreProvider().useContextStorePlain for standalone strict selection | | toolkit.provider.useContextStoreApi | toolkit.useStoreApi() for Resolved access; createStoreProvider().useContextStoreApi() for standalone strict access | | toolkit.provider.useIsInsideProvider | Usually remove; Provider placement now declares scope. For advanced detection use createStoreProvider().useIsInsideProvider() | | toolkit.provider.useContextStoreOptional | Omitted from the toolkit; use advanced createStoreProvider().useProviderStoreOptional() | | toolkit.getProvider() | Remove and use the direct toolkit members above | | toolkit.useResolvedValue | toolkit.useStore | | toolkit.useResolvedStorePlain | toolkit.useStorePlain | | toolkit.useResolvedStoreApi() | toolkit.useStoreApi() | | toolkit.useStore | toolkit.global.useStore | | toolkit.useStorePlain | toolkit.global.useStorePlain | | toolkit.useStoreApi | toolkit.global.store | | createShallowStore().useStoreApi | createShallowStore().store | | provider.useContextStoreOptional | provider.useProviderStoreOptional | | createTransitionAction | Call the Store action inside React's startTransition | | useActionStateAdapter | Compose React's useActionState directly with the Store action | | useOptimisticReducer | Compose React's useOptimistic directly with the Store update |

Change a state creator into an input recipe, provide globalInput, add input to every Provider, and move normal selection to the toolkit's top-level hooks. Outside a Provider they fall back to the Global store; inside nested Providers they select the nearest store. Move request data from Global state to request-local Provider input and use identical decoded input for server render and initial client hydration.

A codemod is intentionally not provided: the old useStore name becomes global.useStore while the new top-level useStore has Resolved semantics, so automated renaming cannot reliably distinguish destructured aliases and application-specific wrappers. Apply the table manually and review each call by intended Store scope.

Lifecycle ordering stays explicit during migration:

<counter.Provider
  input={decodedInput}
  onStoreInit={(store) => {
    // Synchronous, before descendants observe this Store. Do not start external effects here.
    validateInitialState(store.getState());
  }}
  onStoreReady={(store) => {
    // Post-commit, at most once per Store lifetime: connect external integrations here.
    analytics.observe(store);
  }}
>
  <App />
</counter.Provider>

Selection semantics

Shallow hooks retain the prior selected reference while zustand/shallow (or supplied custom equality) considers the next value equal. The cache is discarded when the selected Store identity changes. Plain hooks use Zustand's default equality. Omitting a selector returns the full state.

Shallow-first selection

Shallow selection is useful for object and array picks because an equivalent result retains its previous reference:

const selection = counter.useStore((state) => ({
  count: state.count,
  increment: state.increment,
}));

Supply custom equality when the domain has a stronger equivalence rule:

const stableUser = account.useStore(
  (state) => state.user,
  (left, right) => left?.id === right?.id
);

Retained selection never crosses Store identities. Moving a component into or out of a Provider, or between nested Providers, discards the old Store's cached value.

Plain selection

Use the explicit Plain hooks for Zustand's default selector behavior:

const resolvedValue = counter.useStorePlain((state) => state.count);
const globalValue = counter.global.useStorePlain((state) => state.count);
const providerValue = wizard.useContextStorePlain((state) => state.step);

Middleware support

Zustand middleware belongs in the Store creator returned by a recipe. Middleware-enhanced capabilities remain available on Global, Provider, and Resolved Store handles.

import { createStoreToolkit } from "@okyrychenko-dev/react-zustand-toolkit";
import { devtools, persist } from "zustand/middleware";

type CounterMutators = [
  ["zustand/persist", CounterStore],
  ["zustand/devtools", never],
];

const counter = createStoreToolkit<CounterStore, CounterInput, CounterMutators>(
  ({ initialCount }) =>
    persist(
      devtools(
        (set) => ({
          count: initialCount,
          increment: () => set((state) => ({ count: state.count + 1 })),
        }),
        { name: "CounterStore" }
      ),
      { name: "counter-store" }
    ),
  { globalInput: { initialCount: 0 } }
);

counter.global.store.persist.rehydrate();
counter.global.store.devtools.cleanup();

This library does not auto-connect Provider stores to Redux DevTools. Apply devtools(...) in the recipe when each Store instance should expose that middleware capability.

With browser persistence, also follow the controlled hydration guidance above so stored state does not change the initial client render before React hydration completes.

TypeScript and subscriptions

Store mutator types are preserved through the public Store handles. For example, subscribeWithSelector retains its selector-aware subscription overload:

import { createShallowStore } from "@okyrychenko-dev/react-zustand-toolkit";
import { subscribeWithSelector } from "zustand/middleware";

interface FilterStore {
  query: string;
  setQuery: (query: string) => void;
}

const filter = createShallowStore<FilterStore, [["zustand/subscribeWithSelector", never]]>(
  subscribeWithSelector((set) => ({
    query: "",
    setQuery: (query) => set({ query }),
  }))
);

const unsubscribe = filter.store.subscribe(
  (state) => state.query,
  (nextQuery) => console.log(nextQuery)
);

unsubscribe();

Replacing the removed React 19 helpers

The old helpers were thin wrappers around React primitives and are no longer package exports.

Use startTransition instead of createTransitionAction:

import { startTransition } from "react";

function incrementInTransition(): void {
  startTransition(() => {
    counter.global.store.getState().increment();
  });
}

Use useActionState directly instead of useActionStateAdapter:

import { useActionState } from "react";

const [status, submit, isPending] = useActionState(
  (_previousStatus: string, payload: FormData) => action(payload),
  "idle"
);

Use useOptimistic directly instead of useOptimisticReducer:

import { startTransition, useOptimistic } from "react";

const [optimisticTodos, addOptimisticTodo] = useOptimistic(
  todos,
  (current, nextTodo: Todo) => [...current, nextTodo]
);

startTransition(() => addOptimisticTodo(todo));

Development

pnpm install --frozen-lockfile
pnpm run check
pnpm run test:coverage
pnpm run package:check

License

MIT © Oleksii Kyrychenko