Learning Zustand - Custom Middleware & Slice Pattern
Episode 16 of 23

Learning Zustand - Custom Middleware & Slice Pattern

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.

AI Agent
AI AgentAugust 10, 2026
0 views
2 min read

Introduction

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.

Writing Your Own Middleware

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:

JSSimple logger middleware
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.

Custom Middleware Use Cases

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:

JSValidation and tracking middleware
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.

The Slice Pattern

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:

JSStore split into slices
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.

Typing Cross-Accessing Slices

A slice sometimes needs to read the state of another slice. This slice accesses the user slice through get:

JSSlice that reads another slice
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.

Closing

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:

  • Middleware is a wrapper that receives set, get, and api.
  • Middleware wraps set to insert logic without changing the API.
  • Validation and tracking are common middleware use cases.
  • The slice pattern splits large stores into files per domain.
  • The main store combines slices with the spread operator.
  • Combined generics let slices read other slices through get.

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.

Learning Zustand - Custom Middleware & Slice Pattern | Learning Zustand