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

zustand-middleware-pipe

v1.0.0

Published

Experimental userland helper for composing Zustand middlewares left-to-right.

Readme

zustand-middleware-pipe

Translations: 한국어 · 日本語

npm version license ESM only

Write stacked Zustand middleware in the order you actually think about it.

Quick start

npm install zustand-middleware-pipe zustand
import { create } from 'zustand'
import { definePipeableMiddleware, pipe } from 'zustand-middleware-pipe'
import { immer } from 'zustand-middleware-pipe/middleware/immer'
import { devtools, persist, subscribeWithSelector } from 'zustand-middleware-pipe/middleware'

const useCounterStore = create<CounterState>()(
  pipe
    .use(devtools({ name: 'CounterStore' }))
    .use(subscribeWithSelector())
    .use(persist<CounterState>({ name: 'counter' }))
    .use(immer())
    .create((set) => ({
      count: 0,
      inc: () => set((state) => { state.count += 1 }, false, 'counter/inc'),
    })),
)

That is it. No magic store replacement. No new state model. Just a tiny userland helper that makes dense middleware stacks readable again.

This package is based on the idea discussed in pmndrs/zustand#3449.


The problem: middleware stacks read backwards

Zustand middleware is written as nested wrappers around a base creator. The outermost wrapper is easy to see, but the base creator is buried at the deepest level. Once you have four middleware in the stack, options and state logic spread across several indentation levels:

// wrapper order: devtools → subscribeWithSelector → persist → immer → base creator
const useCounterStore = create<CounterState>()(
  devtools(
    subscribeWithSelector(
      persist(
        immer((set) => ({
          count: 0,
          inc: () => set((state) => { state.count += 1 }),
        })),
        { name: 'counter' },
      ),
    ),
    { name: 'CounterStore' },
  ),
)

Options end up scattered: persist options are buried in the middle, devtools options sit at the bottom of the file, and the next reader has to mentally unwrap every layer just to understand the store shape.

The fix: write the stack as a pipeline

pipe is a middleware pipeline builder. It lets the same wrapper stack be written top-to-bottom, with the base creator last:

// writing order matches the nested wrapper expression ↓
pipe
  .use(devtools({ name: 'CounterStore' }))
  .use(subscribeWithSelector())
  .use(persist<CounterState>({ name: 'counter' }))
  .use(immer())
  .create((set) => ({ ... }))

The evaluation result is identical:

// pipe produces exactly this at runtime
devtools(subscribeWithSelector(persist(immer(baseCreator), options)), options)

Before / After

| | Before (inside-out) | After (pipe) | |---|---|---| | Reading order | Outermost wrapper first, base creator buried deepest | Top-to-bottom, ending at .create(...) | | Options location | Scattered across nesting levels | Inline with each .use(...) call | | set type | Inferred by nesting | Accumulated by the builder chain | | Adding middleware | Wrap the whole expression again | Insert .use(...) at the matching wrapper position |

Refactoring existing stores with an LLM

For larger stores, use docs/llm-context.md as the canonical LLM input. If your tool supports file references, attach or mention @docs/llm-context.md; otherwise paste the document contents before the store code.

Use `docs/llm-context.md` as the rules for this refactor.

Refactor this Zustand store to use `zustand-middleware-pipe`.

- Preserve runtime behavior first.
- Keep the middleware wrapper order unchanged: outermost wrapper first, base creator last.
- Move wrappers into `pipe.use(...)` in the same outside-to-inside order.
- Put the innermost state creator into `.create(...)`.
- Run typecheck and relevant tests after the refactor.

The context file contains the full import rules, type caveats, before/after examples, and safety checklist. This works best for dense stacks like devtools(subscribeWithSelector(persist(immer(...)))), where options are scattered across several indentation levels.


Install

npm install zustand-middleware-pipe zustand

Install immer only if your stack includes immer():

npm install immer

Install zundo only if your stack includes temporal():

npm install zundo

This package is ESM-only and assumes bundler-style module resolution for its internal relative imports.


API

pipe.use(devtools(options?))
  .use(subscribeWithSelector())
  .use(persist<T, PersistedState>(options))
  .use(immer())
  .create(baseCreator)

Middleware wrappers

immer()
temporal(options?)              // use from /middleware/zundo
persist<T, PersistedState = T, PersistReturn = unknown>(options)
subscribeWithSelector()
devtools(options?)
combine(initialState, creator)   // use inside .create()
redux(reducer, initialState)     // use inside .create()

Import paths

Core wrappers and storage helpers come from the middleware barrel. Optional adapters stay on dedicated subpaths so their peer packages are only needed when you import them:

import { pipe } from 'zustand-middleware-pipe'

import { immer } from 'zustand-middleware-pipe/middleware/immer'
import { temporal } from 'zustand-middleware-pipe/middleware/zundo'

import {
  combine,
  createJSONStorage,
  devtools,
  persist,
  redux,
  subscribeWithSelector,
} from 'zustand-middleware-pipe/middleware'

How the builder chain provides type safety

pipe.use(...) accumulates mutator types as you add middleware. The final .create(baseCreator) receives the fully-composed set type, so TypeScript checks each middleware's contribution:

  • .use(immer()) → draft mutation becomes valid inside .create(...)
  • .use(devtools(...)) → action-name third argument to set becomes valid
  • .use(subscribeWithSelector()) → selector-subscribe overload appears on the store

Remove a .use(...) call, and the corresponding capability disappears from the type immediately:

create<CounterState>()(
  pipe
    .use(persist<CounterState>({ name: 'counter' }))
    // .use(immer()) ← remove this and the draft mutation below is a TypeScript error
    .create((set) => ({
      count: 0,
      inc: () =>
        set((state) => {
          state.count += 1 // ← error: set does not accept a function here
        }),
    })),
)

persist with partial state

Pass the persisted-state type as a second generic when using partialize:

pipe
  .use(devtools({ name: 'CounterStore' }))
  .use(subscribeWithSelector())
  .use(persist<CounterState, Pick<CounterState, 'count'>>({
    name: 'counter',
    partialize: (state) => ({ count: state.count }),
  }))
  .create((set) => ({
    count: 0,
    inc: () => set((state) => ({ count: state.count + 1 }), false, 'counter/inc'),
  }))

zundo temporal history

Use the zundo subpath when you want undo/redo history. Install zundo separately, then add temporal() to the pipe:

import { temporal } from 'zustand-middleware-pipe/middleware/zundo'

const useCounterStore = create<CounterState>()(
  pipe
    .use(temporal<CounterState>({ limit: 50 }))
    .create((set) => ({
      count: 0,
      inc: () => set((state) => ({ count: state.count + 1 })),
    })),
)

useCounterStore.temporal.getState().undo()

temporal() keeps zundo's own mutator typing, including store.temporal. If you also persist the main store or the temporal store, follow zundo's wrapTemporal guidance; this package does not infer a universal safe order for every zundo + persistence setup.

You can also use pipe inside wrapTemporal when you want to persist the undo/redo history store itself:

import { pipe } from 'zustand-middleware-pipe'
import { createJSONStorage, persist } from 'zustand-middleware-pipe/middleware'
import {
  temporal,
  type TemporalState,
} from 'zustand-middleware-pipe/middleware/zundo'

type CounterHistory = TemporalState<CounterState>
type PersistedCounterHistory = Pick<CounterHistory, 'futureStates' | 'pastStates'>

pipe
  .use(
    temporal<CounterState>({
      wrapTemporal: (temporalCreator) =>
        pipe
          .use(
            persist<CounterHistory, PersistedCounterHistory>({
              name: 'counter-history',
              storage: createJSONStorage<PersistedCounterHistory>(() => localStorage),
              partialize: (history) => ({
                futureStates: history.futureStates,
                pastStates: history.pastStates,
              }),
            }),
          )
          .create(temporalCreator),
    }),
  )
  .create((set) => ({ ... }))

The outer pipe composes the main store. The inner pipe composes zundo's temporal history store.

combine and redux

combine and redux are Zustand state-creator helpers, not middleware, so they belong inside .create(...):

const useCombinedCounterStore = create<CounterState>()(
  pipe
    .use(devtools({ name: 'CombinedCounterStore' }))
    .create(
      combine({ count: 0 }, (set) => ({
        inc: () => set((state) => ({ count: state.count + 1 })),
      })),
    ),
)

type CounterAction = { type: 'inc' }
type Dispatch = (action: CounterAction) => CounterAction

const useReduxCounterStore = create<CounterState & { dispatch: Dispatch }>()(
  pipe
    .use(devtools({ name: 'ReduxCounterStore' }))
    .create(redux(reducer, { count: 0 })),
)

Userland middleware metadata

definePipeableMiddleware tags a correctly typed userland middleware with an explicit id and optional policy metadata. The helper returns the same middleware function, so Zustand mutator tuple typing still comes from the middleware itself:

const temporal = definePipeableMiddleware(temporalMiddleware, {
  id: 'zundo/temporal',
  duplicate: 'reject',
  order: {
    after: ['zustand/persist'],
    before: ['zustand/immer'],
  },
})

pipe
  .use(persist<CounterState>({ name: 'counter' }))
  .use(temporal)
  .use(immer())
  .create((set) => ({ ... }))

order.before and order.after are checked only against ids that are present in the current pipe chain. Unknown or absent targets are ignored, cycles are rejected, and reserved built-in ids such as zustand/persist cannot be reused by public userland metadata.


Important caveats

  • Not official Zustand guidance. This is a userland experiment.
  • Do not rewrite working stores just to use this helper.
  • Bundler resolution is expected. The package is emitted as ESM with extensionless internal relative imports, so consume it through a bundler-compatible toolchain.
  • Built-in wrapper order and duplicates are enforced. Add package-provided wrappers outer-to-inner: .use(devtools(...)).use(subscribeWithSelector()).use(persist(...)).use(immer()). TypeScript and the runtime .use(...) boundary reject reversed built-in order and duplicate package built-ins.
  • Runtime guards are scoped to tagged pipeable wrappers. Package built-ins (devtools, subscribeWithSelector, persist, immer) and opt-in adapters such as zundo carry explicit metadata for duplicate/order checks. Arbitrary untagged, userland, or third-party middleware is not introspected for order or duplicates.
  • Userland order metadata is opt-in. definePipeableMiddleware only trusts explicit ids and order.before / order.after hints. It does not inspect function names or source code, and it does not make arbitrary third-party middleware automatically safe.
  • Direct reexports keep Zustand helper semantics. combine, redux, and createJSONStorage are direct Zustand helpers; combine and redux belong inside .create(...), not .use(...), and immer remains available from the dedicated zustand-middleware-pipe/middleware/immer subpath.
  • Optional third-party adapters stay on dedicated subpaths. zundo is available from zustand-middleware-pipe/middleware/zundo and requires installing zundo in the consuming app.
  • store.devtools availability depends on normal Zustand devtools behavior. It may not exist when devtools are disabled or the Redux DevTools extension is absent.
  • Third-party middleware is not automatically composable. Wrappers need correct mutator tuple types to work with the builder.

Development

npm install
npm run verify

npm run verify runs typecheck, Vitest, and declaration build.

Example

The Vite React example lives in examples/ and uses this package through a local file:.. dependency.

npm run example:install
npm run example:dev

Use npm run example:verify to build and lint the example from the package root.