zustand-middleware-pipe
v1.0.0
Published
Experimental userland helper for composing Zustand middlewares left-to-right.
Maintainers
Readme
zustand-middleware-pipe
Write stacked Zustand middleware in the order you actually think about it.
Quick start
npm install zustand-middleware-pipe zustandimport { 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 zustandInstall immer only if your stack includes immer():
npm install immerInstall zundo only if your stack includes temporal():
npm install zundoThis 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 tosetbecomes 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 aszundocarry 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.
definePipeableMiddlewareonly trusts explicit ids andorder.before/order.afterhints. 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, andcreateJSONStorageare direct Zustand helpers;combineandreduxbelong inside.create(...), not.use(...), andimmerremains available from the dedicatedzustand-middleware-pipe/middleware/immersubpath. - Optional third-party adapters stay on dedicated subpaths.
zundois available fromzustand-middleware-pipe/middleware/zundoand requires installingzundoin the consuming app. store.devtoolsavailability 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 verifynpm 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:devUse npm run example:verify to build and lint the example from the package root.
