🍜 Zundo

enable time-travel in your apps. undo/redo middleware for zustand. built with zustand. <1 kB

gif displaying undo feature

Build Size Version Downloads

Try a live demo

Install

npm i zustand zundo

zustand v4.3.0 or higher is required for TS usage. v4.0.0 or higher is required for JS usage. Node 16 or higher is required.

Background

  • Solves the issue of managing state in complex user applications
  • "It Just Works" mentality
  • Small and fast
  • Provides simple middleware to add undo/redo capabilities
  • Leverages zustand for state management
  • Works with multiple stores in the same app
  • Has an unopinionated and extensible API
Bear wearing a button up shirt textured with blue recycle symbols eating a bowl of noodles with chopsticks.

First create a vanilla store with temporal middleware

This returns the familiar store accessible by a hook! But now your store tracks past actions.

import { create } from 'zustand';
import { temporal } from 'zundo';

// define the store (typescript)
interface StoreState {
  bears: number;
  increasePopulation: () => void;
  removeAllBears: () => void;
}

// creates a store with undo/redo capability
const useStoreWithUndo = create<StoreState>()(
  temporal((set) => ({
    bears: 0,
    increasePopulation: () => set((state) => ({ bears: state.bears + 1 })),
    removeAllBears: () => set({ bears: 0 }),
  })),
);

Convert to React Store

If you're using React, you can convert the store to a React hook using create from zustand.

import { useStore } from 'zustand';
import type { TemporalState } from 'zundo';

const useTemporalStore = <T,>(
  selector: (state: TemporalState<StoreState>) => T,
  equality?: (a: T, b: T) => boolean,
) => useStore(originalStore.temporal, selector, equality);

Then bind your components

Use your store anywhere, including undo, redo, and clear!

const App = () => {
  const { bears, increasePopulation, removeAllBears } = useStoreWithUndo();
  const { undo, redo, clear } = useTemporalStore((state) => state);
  // or if you don't use create from zustand, you can use the store directly.
  // } = useStoreWithUndo.temporal.getState();
  // if you want reactivity, you'll need to subscribe to the temporal store.

  return (
    <>
      bears: {bears}
      <button onClick={() => increasePopulation}>increase</button>
      <button onClick={() => removeAllBears}>remove</button>
      <button onClick={() => undo()}>undo</button>
      <button onClick={() => redo()}>redo</button>
      <button onClick={() => clear()}>clear</button>
    </>
  );
};

API

The Middleware

(config: StateCreator, options?: ZundoOptions) => StateCreator

zundo has one export: temporal. It is used to as middleware for create from zustand. The config parameter is your store created by zustand. The second options param is optional and has the following API.

Middleware Options

type onSave<TState> =
  | ((pastState: TState, currentState: TState) => void)
  | undefined;

export interface ZundoOptions<TState, PartialTState = TState> {
  partialize?: (state: TState) => PartialTState;
  limit?: number;
  equality?: (pastState: TState, currentState: TState) => boolean;
  diff?: (
    pastState: Partial<PartialTState>,
    currentState: Partial<PartialTState>,
  ) => Partial<PartialTState> | null;
  onSave?: onSave<TState>;
  handleSet?: (
    handleSet: StoreApi<TState>['setState'],
  ) => StoreApi<TState>['setState'];
  pastStates?: Partial<PartialTState>[];
  futureStates?: Partial<PartialTState>[];
  wrapTemporal?: (
    storeInitializer: StateCreator<
      _TemporalState<TState>,
      [StoreMutatorIdentifier, unknown][],
      []
    >,
  ) => StateCreator<
    _TemporalState<TState>,
    [StoreMutatorIdentifier, unknown][],
    [StoreMutatorIdentifier, unknown][]
  >;
}

Exclude fields from being tracked in history

partialize?: (state: TState) => PartialTState

Use the partialize option to omit or include specific fields. Pass a callback that returns the desired fields. This can also be used to exclude fields. By default, the entire state object is tracked.

// Only field1 and field2 will be tracked
const useStoreA = create<StoreState>(
  temporal(
    (set) => ({
      // your store fields
    }),
    {
      partialize: (state) => {
        const { field1, field2, ...rest } = state;
        return { field1, field2 };
      },
    },
  ),
);

// Everything besides field1 and field2 will be tracked
const useStoreB = create<StoreState>(
  temporal(
    (set) => ({
      // your store fields
    }),
    {
      partialize: (state) => {
        const { field1, field2, ...rest } = state;
        return rest;
      },
    },
  ),
);

Limit number of states stored

limit?: number

For performance reasons, you may want to limit the number of previous and future states stored in history. Setting limit will limit the number of previous and future states stored in the temporal store. When the limit is reached, the oldest state is dropped. By default, no limit is set.

const useStore = create<StoreState>(
  temporal(
    (set) => ({
      // your store fields
    }),
    { limit: 100 },
  ),
);

Prevent unchanged states to be stored

equality?: (pastState: TState, currentState: TState) => boolean

For performance reasons, you may want to use a custom equality function to determine when a state change should be tracked. You can write your own or use something like fast-equals, fast-deep-equal, zustand/shallow, lodash.isequal, or underscore.isEqual. By default, all state changes to your store are tracked.

import { shallow } from 'zustand/shallow';

// Use an existing equality function
const useStoreA = create<StoreState>(
  temporal(
    (set) => ({
      // your store fields
    }),
    { equality: shallow },
  ),
);

// Write your own equality function
const useStoreB = create<StoreState>(
  temporal(
    (set) => ({
      // your store fields
    }),
    { equality: (a, b) => a.field1 !== b.field1 },
  ),
);

Store state delta rather than full object

diff?: (pastState: Partial<PartialTState>, currentState: Partial<PartialTState>) => Partial<PartialTState> | null

For performance reasons, you may want to store the state delta rather than the complete (potentially partialized) state object. This can be done by passing a diff function. The diff function should return an object that represents the difference between the past and current state. By default, the full state object is stored.

If diff returns null, the state change will not be tracked. This is helpful for a conditionally storing past states or if you have a doNothing action that does not change the state.

You can write your own or use something like microdiff, just-diff, or deep-object-diff.

const useStore = create<StoreState>(
  temporal(
    (set) => ({
      // your store fields
    }),
    {
      diff: (pastState, currentState) => {
        const myDiff = diff(currentState, pastState);
        const newStateFromDiff = myDiff.reduce(
          (acc, difference) => {
            type Key = keyof typeof currentState;
            if (difference.type === 'CHANGE') {
              const pathAsString = difference.path.join('.') as Key;
              acc[pathAsString] = difference.value;
            }
            return acc;
          },
          {} as Partial<typeof currentState>,
        );
        return isEmpty(newStateFromDiff) ? null : newStateFromDiff;
      },
    },
  ),
);

Callback when temporal store is updated

onSave?: (pastState: TState, currentState: TState) => void

Sometimes, you may need to call a function when the temporal store is updated. This can be configured using onSave in the options, or by programmatically setting the callback if you need lexical context (see the TemporalState API below for more information).

import { shallow } from 'zustand/shallow';

const useStoreA = create<StoreState>(
  temporal(
    (set) => ({
      // your store fields
    }),
    { onSave: (state) => console.log('saved', state) },
  ),
);

Cool-off period

handleSet?: (handleSet: StoreApi<TState>['setState']) => StoreApi<TState>['setState']

Sometimes multiple state changes might happen in a short amount of time and you only want to store one change in history. To do so, we can utilize the handleSet callback to set a timeout to prevent new changes from being stored in history. This can be used with something like throttle-debounce, just-throttle, just-debounce-it, lodash.throttle, or lodash.debounce. This a way to provide middleware to the temporal store's setter function.

const withTemporal = temporal<MyState>(
  (set) => ({
    // your store fields
  }),
  {
    handleSet: (handleSet) =>
      throttle<typeof handleSet>((state) => {
        console.info('handleSet called');
        handleSet(state);
      }, 1000),
  },
);

Initialize temporal store with past and future states

pastStates?: Partial<PartialTState>[]

futureStates?: Partial<PartialTState>[]

You can initialize the temporal store with past and future states. This is useful when you want to load a previous state from a database or initialize the store with a default state. By default, the temporal store is initialized with an empty array of past and future states.

Note: The pastStates and futureStates do not respect the limit set in the options. If you want to limit the number of past and future states, you must do so manually prior to initializing the store.

const withTemporal = temporal<MyState>(
  (set) => ({
    // your store fields
  }),
  {
    pastStates: [{ field1: 'value1' }, { field1: 'value2' }],
    futureStates: [{ field1: 'value3' }, { field1: 'value4' }],
  },
);

Wrap temporal store

wrapTemporal?: (storeInitializer: StateCreator<_TemporalState<TState>, [StoreMutatorIdentifier, unknown][], []>) => StateCreator<_TemporalState<TState>, [StoreMutatorIdentifier, unknown][], [StoreMutatorIdentifier, unknown][]>

You can wrap the temporal store with your own middleware. This is useful if you want to add additional functionality to the temporal store. For example, you can add persist middleware to the temporal store to persist the past and future states to local storage.

For a full list of middleware, see zustand middleware and third-party zustand libraries.

Note: The temporal middleware can be added to the temporal store. This way, you could track the history of the history. 🀯

import { persist } from 'zustand/middleware';

const withTemporal = temporal<MyState>(
  (set) => ({
    // your store fields
  }),
  {
    wrapTemporal: (storeInitializer) =>
      persist(storeInitializer, { name: 'temporal-persist' }),
  },
);

useStore.temporal

When using zustand with the temporal middleware, a temporal object is attached to your vanilla or React-based store. temporal is a vanilla zustand store: see StoreApi from zustand for more details.

Use temporal.getState() to access to temporal store!

While setState, subscribe, and destroy exist on temporal, you should not need to use them.

React Hooks

To use within React hooks, we need to convert the vanilla store to a React-based store using create from zustand. This is done by passing the vanilla store to create from zustand.

import { create } from 'zustand';
import { temporal } from 'zundo';

const useStore = create(
  temporal(
    (set) => ({
      // your store fields
    }),
    {
      // temporal options
    },
  ),
);

const useTemporalStore = create(useStore.temporal);

useStore.temporal.getState()

temporal.getState() returns the TemporalState which contains undo, redo, and other helpful functions and fields.

interface TemporalState<TState> {
  pastStates: TState[];
  futureStates: TState[];

  undo: (steps?: number) => void;
  redo: (steps?: number) => void;
  clear: () => void;

  isTracking: boolean;
  pause: () => void;
  resume: () => void;

  setOnSave: (onSave: onSave<TState>) => void;
}

Going back in time

pastStates: TState[]

pastStates is an array of previous states. The most recent previous state is at the end of the array. This is the state that will be applied when undo is called.

Forward to the future

futureStates: TState[]

futureStates is an array of future states. States are added when undo is called. The most recent future state is at the end of the array. This is the state that will be applied when redo is called. The future states are the "past past states."

Back it up

undo: (steps?: number) => void

undo: call function to apply previous state (if there are previous states). Optionally pass a number of steps to undo to go back multiple state at once.

Take it back now y'all

redo: (steps?: number) => void

redo: call function to apply future state (if there are future states). Future states are "previous previous states." Optionally pass a number of steps to redo go forward multiple states at once.

Remove all knowledge of time

clear: () => void

clear: call function to remove all stored states from your undo store. Sets pastStates and futureStates to arrays with length of 0. Warning: clearing cannot be undone.

Dispatching a new state will clear all of the future states.

Stop and start history

isTracking: boolean

isTracking: a stateful flag in the temporal store that indicates whether the temporal store is tracking state changes or not. Possible values are true or false. To programmatically pause and resume tracking, use pause() and resume() explained below.

Pause tracking of history

pause: () => void

pause: call function to pause tracking state changes. This will prevent new states from being stored in history within the temporal store. Sets isTracking to false.

Resume tracking of history

resume: () => void

resume: call function to resume tracking state changes. This will allow new states to be stored in history within the temporal store. Sets isTracking to true.

Programmatically add middleware to the setter

setOnSave: (onSave: (pastState: State, currentState: State) => void) => void

setOnSave: call function to set a callback that will be called when the temporal store is updated. This can be used to call the temporal store setter using values from the lexical context. This is useful when needing to throttle or debounce updates to the temporal store.

Community

zundo is used by several projects and teams including Stability AI, Yext, KaotoIO, and NutSH.ai.

If this library is useful to you, please consider sponsoring the project. Thank you!

PRs are welcome! pnpm is used as a package manager. Run pnpm install to install local dependencies. Thank you for contributing!

Examples

Migrate from v1 to v2

Click to expand

v2.0.0 - Smaller and more flexible

v2.0.0 is a complete rewrite of zundo. It is smaller and more flexible. It also has a smaller bundle size and allows you to opt into specific performance trade-offs. The API has changed slightly. See the API section for more details. Below is a summary of the changes as well as steps to migrate from v1 to v2.

Breaking Changes

Middleware Option Changes

  • include and exclude options are now handled by the partialize option.
  • allowUnchanged option is now handled by the equality option. By default, all state changes are tracked. In v1, we bundled lodash.isequal to handle equality checks. In v2, you are able to use any function.
  • historyDepthLimit option has been renamed to limit.
  • coolOffDurationMs option is now handled by the handleSet option by wrapping the setter function with a throttle or debounce function.

Import changes

  • The middleware is called temporal rather than undoMiddleware.

New Features

New Options

  • partialize option to omit or include specific fields. By default, the entire state object is tracked.
  • limit option to limit the number of previous and future states stored in history.
  • equality option to use a custom equality function to determine when a state change should be tracked. By default, all state changes are tracked.
  • diff option to store state delta rather than full object.
  • onSave option to call a function when the temporal store is updated.
  • handleSet option to throttle or debounce state changes.
  • pastStates and futureStates options to initialize the temporal store with past and future states.
  • wrapTemporal option to wrap the temporal store with middleware. The temporal store is a vanilla zustand store.

New temporal.getState() API

  • undo, redo, and clear functions are now always defined. They can no longer be undefined.
  • undo() and redo() functions now accept an optional steps parameter to go back or forward multiple states at once.
  • isTracking flag, and pause, and resume functions are now available on the temporal store.
  • setOnSave function is now available on the temporal store to change the onSave behavior after the store has been created.

Migration Steps

  1. Update zustand to v4.3.0 or higher
  2. Update zundo to v2.0.0 or higher
  3. Update your store to use the new API
  4. Update imports
- import { undoMiddleware } from 'zundo';
+ import { temporal } from 'zundo';
  • If you're using include or exclude, use the new partialize option
// v1.6.0
// Only field1 and field2 will be tracked
const useStoreA = create<StoreState>(
  undoMiddleware(
    set => ({ ... }),
    { include: ['field1', 'field2'] }
  )
);

// Everything besides field1 and field2 will be tracked
const useStoreB = create<StoreState>(
  undoMiddleware(
    set => ({ ... }),
    { exclude: ['field1', 'field2'] }
  )
);

// v2.0.0
// Only field1 and field2 will be tracked
const useStoreA = create<StoreState>(
  temporal(
    (set) => ({
      // your store fields
    }),
    {
      partialize: (state) => {
        const { field1, field2, ...rest } = state;
        return { field1, field2 };
      },
    },
  ),
);

// Everything besides field1 and field2 will be tracked
const useStoreB = create<StoreState>(
  temporal(
    (set) => ({
      // your store fields
    }),
    {
      partialize: (state) => {
        const { field1, field2, ...rest } = state;
        return rest;
      },
    },
  ),
);
  • If you're using allowUnchanged, use the new equality option
// v1.6.0
// Use an existing `allowUnchanged` option
const useStore = create<StoreState>(
  undoMiddleware(
    set => ({ ... }),
    { allowUnchanged: true }
  )
);

// v2.0.0
// Use an existing equality function
import { shallow } from 'zustand/shallow'; // or use `lodash.isequal` or any other equality function

// Use an existing equality function
const useStoreA = create<StoreState>(
  temporal(
    (set) => ({
      // your store fields
    }),
    { equality: shallow },
  ),
);
  • If you're using historyDepthLimit, use the new limit option
// v1.6.0
// Use an existing `historyDepthLimit` option
const useStore = create<StoreState>(
  undoMiddleware(
    set => ({ ... }),
    { historyDepthLimit: 100 }
  )
);

// v2.0.0
// Use `limit` option
const useStore = create<StoreState>(
  temporal(
    (set) => ({
      // your store fields
    }),
    { limit: 100 },
  ),
);
  • If you're using coolOffDurationMs, use the new handleSet option
// v1.6.0
// Use an existing `coolOffDurationMs` option
const useStore = create<StoreState>(
  undoMiddleware(
    set => ({ ... }),
    { coolOfDurationMs: 1000 }
  )
);

// v2.0.0
// Use `handleSet` option
const withTemporal = temporal<MyState>(
  (set) => ({
    // your store fields
  }),
  {
    handleSet: (handleSet) =>
      throttle<typeof handleSet>((state) => {
        console.info('handleSet called');
        handleSet(state);
      }, 1000),
  },
);

Road Map

  • create nicer API, or a helper hook in react land (useTemporal). or vanilla version of the it
  • support history branches rather than clearing the future states
  • track state for multiple stores at once

Author

Charles Kornoelje (@_charkour)

Versioning

View the releases for the change log. This project follows semantic versioning.

Illustration Credits

Ivo Ilić (@theivoson)