How to Implement Undo/Redo Functionality with Zustand: A Complete Guide

Implement undo/redo functionality with Zustand by wrapping your store with the zundo middleware, which maintains an internal history stack and injects undo(), redo(), and clear() actions into your store API.

Zustand's core API intentionally remains minimal to ensure maximum flexibility, providing only a plain-object store, a setState updater, and a subscribe listener. Because the library avoids baking in advanced features, implementing undo/redo functionality with Zustand requires leveraging the community-maintained zundo middleware, which is officially listed in the pmndrs/zustand repository's third-party integrations.

Understanding Zustand's Middleware Architecture

Zustand achieves extensibility through middleware that intercepts the set function and store state. The core implementation in src/react.ts exports the create function that initializes stores, while src/middleware.ts re-exports built-in utilities like devtools and persist.

Because undo/redo requires tracking state snapshots across time, it cannot be implemented efficiently through simple state mutations alone. The zundo middleware solves this by sitting between your store logic and Zustand's internal setState, capturing snapshots before they change and maintaining a history stack independent of your application's state shape.

How the Zundo Middleware Implements Time-Travel

The zundo middleware, referenced in docs/reference/integrations/third-party-libraries.md, operates through four core mechanisms:

  1. State History Stack – Each time your store's set function executes, zundo pushes a snapshot of the previous state onto an internal history array.
  2. Undo/Redo Pointers – The middleware tracks a current index within the history stack. When you call undo(), it decrements the pointer and rehydrates the store with the snapshot at that position.
  3. Action Filtering – You can provide a filter function to exclude specific actions from history (such as transient UI updates like SELECT_ITEM), preventing the stack from filling with irrelevant states.
  4. Memory Management – The limit option caps the history array length, ensuring long-running applications don't consume unbounded memory.

Basic Implementation: Creating an Undo/Redo Store

To implement undo/redo functionality with Zustand, install zundo alongside zustand, then wrap your store definition with the middleware:

npm install zustand zundo

Create a basic counter store with time-travel capabilities:

import { create } from 'zustand'
import { undo } from 'zundo'

interface CounterState {
  count: number
  inc: () => void
  dec: () => void
}

export const useCounter = create<CounterState>(
  undo((set, get) => ({
    count: 0,
    inc: () => set((state) => ({ count: state.count + 1 })),
    dec: () => set((state) => ({ count: state.count - 1 })),
    
    // These methods are injected by zundo but can be typed explicitly
    undo: () => get().undo?.(),
    redo: () => get().redo?.(),
    clearHistory: () => get().clear?.(),
  }))
)

Calling useCounter.getState().inc() pushes a new snapshot onto the history stack. Invoking undo() restores the previous count value without requiring you to manage the history manually.

Configuring History Limits and Action Filtering

For production applications, you should constrain memory usage and exclude non-essential state changes from the history. The zundo middleware accepts an options object as its first argument:

import { create } from 'zustand'
import { undo } from 'zundo'

interface TodoState {
  items: string[]
  selectedId: string | null
  add: (item: string) => void
  remove: (index: number) => void
  select: (id: string) => void
}

export const useTodo = create<TodoState>(
  undo(
    {
      limit: 50, // Maintain maximum 50 snapshots
      filter: (action) => {
        // Only track state changes, not UI selections
        return action.type !== 'SELECT_ITEM'
      },
      equality: (current, next) => {
        // Custom equality check to avoid duplicate entries
        return JSON.stringify(current) === JSON.stringify(next)
      }
    },
    (set, get) => ({
      items: [],
      selectedId: null,
      add: (item) => set((state) => ({ 
        items: [...state.items, item] 
      })),
      remove: (i) => set((state) => ({
        items: state.items.filter((_, idx) => idx !== i)
      })),
      select: (id) => set({ selectedId: id }) // Filtered from history
    })
  )
)

The limit option prevents unbounded memory growth in long-running sessions, while filter ensures that transient UI state (like hover effects or selection highlights) doesn't pollute the undo stack.

Composing Zundo with Other Middleware

Zustand's middleware composition allows zundo to work alongside built-in utilities like devtools and persist. The order of wrapping determines which middleware intercepts state changes first.

In src/middleware.ts, Zustand exports devtools and persist for common use cases. When combining these with zundo, wrap undo around your store logic, then apply persistence and devtools:

import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'
import { undo } from 'zundo'

interface CartState {
  items: string[]
  add: (item: string) => void
}

export const useCart = create<CartState>(
  devtools(
    persist(
      undo(
        (set, get) => ({
          items: [],
          add: (item) => set((state) => ({ 
            items: [...state.items, item] 
          })),
          // undo(), redo(), clear() available via zundo
        })
      ),
      {
        name: 'cart-storage',
        getStorage: () => localStorage,
      }
    ),
    { name: 'CartStore' }
  )
)

This composition ensures that:

  • undo captures snapshots of the pure state (before persistence serialization)
  • persist saves the current state (and history, if configured) to localStorage
  • devtools tracks all actions, including undo and redo calls, in the Redux DevTools extension

Using Undo/Redo in React Components

Once the middleware is configured, consuming the undo/redo API in React components requires no special hooks. The actions are part of the store's state object:

import { useCounter } from './store/counter'

function Counter() {
  const { count, inc, dec, undo, redo } = useCounter()

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={inc}>+</button>
      <button onClick={dec}>-</button>
      <button onClick={undo}>Undo</button>
      <button onClick={redo}>Redo</button>
    </div>
  )
}

The undo and redo functions are stable references that do not trigger re-renders when called, following Zustand's standard selector optimization patterns.

Summary

  • Zustand's core in src/react.ts and src/middleware.ts provides no built-in undo/redo; time-travel requires external middleware.
  • The zundo middleware, listed in docs/reference/integrations/third-party-libraries.md, implements undo/redo by maintaining an internal history stack of state snapshots.
  • Configuration options include limit (history size), filter (action exclusion), and equality (duplicate detection) to optimize memory and performance.
  • Middleware composition allows zundo to wrap cleanly around persist and devtools, enabling persisted time-travel with full DevTools visibility.
  • React integration requires no additional hooks; undo() and redo() are standard actions available on the store object.

Frequently Asked Questions

Can I implement undo/redo without external middleware?

While technically possible by manually managing a history array within your store's state, Zustand's minimal core in src/react.ts does not provide history management primitives. Implementing time-travel manually requires intercepting every set call, deep-cloning state snapshots, and managing pointer indices—functionality that the zundo middleware already optimizes. For production applications, using the community-maintained middleware is the recommended approach.

How does zundo handle memory management?

The zundo middleware provides a limit configuration option that caps the number of state snapshots stored in the history array. When the limit is reached, the oldest snapshots are discarded from the beginning of the stack while maintaining the current pointer position. Additionally, the filter option allows you to exclude transient state changes (like UI hover states) from being recorded, further reducing memory overhead in long-running applications.

Does zundo work with Zustand's persist middleware?

Yes, zundo composes cleanly with the persist middleware exported from src/middleware.ts. When wrapping your store, apply undo inside persist if you want the history stack itself saved to storage, or outside persist if you only want the current state persisted while keeping history in memory. The middleware order determines whether time-travel state survives page reloads, allowing flexible persistence strategies based on your application's requirements.

Can I use zundo with TypeScript?

The zundo middleware is fully typed and supports TypeScript stores. When defining your store interface, include the undo/redo actions in your type definition or use the utility types provided by the package. The middleware preserves type safety for your state slices while injecting the undo(), redo(), and clear() methods into the store API, ensuring full IntelliSense support in React components and preventing type errors during history navigation.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →