This episode covers two patterns for team-scale stores: custom middleware that wraps set for logging, validation, and tracking, and the slice pattern that splits large stores into independent pieces. You also learn to type slices so they can access each other through get with TypeScript.

At an advanced stage, stores start needing cross-domain behavior: logging every change, validating payloads, or tracking actions. Episode 16 covers two patterns that keep team-scale stores maintainable: custom middleware for inserting logic around set, and the slice pattern for splitting a giant store into independent yet fully typed pieces.
Both are skills frequently asked about in senior interviews and genuinely used in production.
A Zustand middleware is a function that wraps the store initializer. Its basic form receives set, get, and api, then returns wrapped set, get, and api. Here's a logger that records every state change:
import { create } from 'zustand'
const logger = (initializer) => (set, get, api) =>
initializer(
(args) => {
console.log('sebelum:', get())
set(args)
console.log('sesudah:', get())
},
get,
api,
)
export const useCounter = create(logger((set) => ({
count: 0,
increment: () => set((s) => ({ count: s.count + 1 })),
})))logger(initializer) accepts an initializer and returns a new function that wraps set. Every time the store is updated, the log prints the state before and after — without changing the API used by components.
Middleware is useful for repeated cross-cutting concerns. Here's a validation that rejects negative values, and a tracking middleware that records actions to analytics:
const validate = (initializer) => (set, get, api) =>
initializer(
(args) => {
const next =
typeof args === 'function' ? args(get()) : args
if (next.count < 0) {
console.warn('count tidak boleh negatif')
return
}
set(args)
},
get,
api,
)
const track = (initializer) => (set, get, api) =>
initializer(
(args) => {
trackAnalytics('store:update', get())
set(args)
},
get,
api,
)validate rejects invalid updates before they reach the state, while track records actions. Combine them with nested composition: create(validate(track(initializer))) — the wrapping order determines the execution order on update.
When a single store balloons to dozens of actions, split it into slices. A slice is a piece of state plus actions written separately, then combined in a single create:
interface UserSlice {
user: User | null
setUser: (user: User) => void
}
interface CartSlice {
items: Item[]
addItem: (item: Item) => void
}
export const useStore = create<UserSlice & CartSlice>()((...a) => ({
...createUserSlice(...a),
...createCartSlice(...a),
}))Each slice can be written in its own file: userSlice.ts and cartSlice.ts. The main store only combines the results. As the store grows, add a new slice without touching the others — the same pattern used in the official Zustand repo.
A slice sometimes needs to read the state of another slice. This slice accesses the user slice through get:
interface LoggedInSlice {
isLoggedIn: () => boolean
}
const createLoggedInSlice = (set, get) => ({
isLoggedIn: () => get().user !== null,
})
export const useStore = create<UserSlice & LoggedInSlice>()((...a) => ({
...createUserSlice(...a),
...createLoggedInSlice(...a),
}))get().user inside a slice reads another slice's state safely because the combined generic ensures the full type is available. You still get autocomplete and full type-checking across the entire store even though the state is spread across several files.
Episode 16 adds two production tools: custom middleware that wraps set for logging, validation, and tracking, and the slice pattern that splits large stores into typed pieces that can read each other through get.
Key takeaways:
In the next episode we will discuss Zustand outside React — using createStore from zustand/vanilla for non-React code, reading state with getState in Node services and workers, then connecting it back to React with useStore.