zundo

repository·main·Indexed 21 days ago

https://github.com/charkour/zundo

A lightweight (<700 B) undo/redo middleware for Zustand that enables time-travel capabilities. It provides the `temporal` middleware to track historical states, with support for state partialization, history limits, custom equality checks, and state deltas via the `diff` option. It includes a `TemporalState` API for managing undo, redo, and clear operations, and supports reactive state tracking in React components.

Tokens
6.4K
Snippets
21
Records
26
Agent score
67%

What's inside zundo

  1. Use `useTemporalStore` for reactive temporal state

    main

    If you need your React components to re-render when the history changes (e.g., to show the number of past states or disable an undo button when no history exists), use a custom hook built on useStoreWithEqualityFn to subscribe to the temporal object.

    This makes properties like pastStates and futureStates reactive.

    import { useStoreWithEqualityFn } from 'zustand/traditional';
    import type { TemporalState } from 'zundo';
    
    // Implementation of the reactive hook
    function useTemporalStore<T>(selector: (state: TemporalState<MyState>) => T): T {
      return useStoreWithEqualityFn(useStoreWithUndo.temporal, selector!);
    }
    
    const App = () => {
      const { bears, increasePopulation } = useStoreWithUndo();
      
      // This hook makes pastStates and futureStates reactive
      const { undo, redo, clear, pastStates, futureStates } = useTemporalStore(
        (state) => state,
      );
    
      return (
        <>
          <p>bears: {bears}</p>
          <p>pastStates: {JSON.stringify(pastStates)}</p>
          <button onClick={increasePopulation}>increase</button>
          <button onClick={() => undo()}>undo</button>
        </>
      );
    };
  2. Wrap the temporal store with middleware using `wrapTemporal`

    main

    The wrapTemporal option allows you to apply additional Zustand middleware specifically to the internal temporal store created by Zundo. This is useful for persisting the undo/redo history itself (e.g., to localStorage).

    Note: Because Zundo creates a second internal store, you must apply middleware (like persist) both to your main store and inside wrapTemporal to ensure the history is also persisted.

    import { persist } from 'zustand/middleware';
    
    const useStoreWithUndo = create<StoreState>()(
      persist(
        temporal(
          (set) => ({ /* your store fields */ }),
          {
            wrapTemporal: (storeInitializer) => {
              persist(storeInitializer, { name: 'temporal-persist' });
            },
          }
        )
      )
    );
  3. Migrate from zundo v1 to v2

    main

    v2.0.0 is a complete rewrite that is smaller and more flexible. Key changes include:

    Breaking Changes

    • Middleware Name: undoMiddleware is now temporal.
    • Field Filtering: include and exclude are replaced by the partialize option.
    • Equality Checks: allowUnchanged is replaced by the equality option (accepts any equality function).
    • History Limit: historyDepthLimit is renamed to limit.
    • Throttling/Debouncing: coolOffDurationMs is replaced by the handleSet option, which allows you to wrap the setter function with a throttle or debounce function.

    Migration Steps

    1. Update zustand to v4.3.0 or higher.
    2. Update zundo to v2.0.0 or higher.
    3. Update imports from undoMiddleware to temporal.
    4. Update configuration options as described in the code examples below.
    - import { undoMiddleware } from 'zundo';
    + import { temporal } from 'zundo';
  4. Store state deltas using `diff`

    main

    For high-performance scenarios or large state objects, you can store only the changes (deltas) instead of full state snapshots. Provide a diff function in ZundoOptions that returns an object representing the difference between pastState and currentState.

    • If diff returns a partial object, that object is stored in history.
    • If diff returns null, the state change is not tracked (useful for ignoring certain actions).
    const useStoreWithUndo = create<StoreState>()(
      temporal(
        (set) => ({ /* ... */ }),
        {
          diff: (pastState, currentState) => {
            // Implementation logic to return the difference
            // or null if no change should be tracked
            return myDiffResult;
          },
        },
      ),
    );
  5. Control history recording with `equality`

    main

    By default, Zundo records a snapshot whenever any Zustand state setter is called, even if the values haven't changed. To prevent unnecessary history entries, provide an equality function. This function compares the pastState and currentState and returns true if they are considered equal (meaning no history should be saved).

    You can use:

    • Deep equality functions (e.g., fast-deep-equal).
    • Shallow equality functions (e.g., zustand/shallow).
    • Custom logic for specific field comparisons.
    import isDeepEqual from 'fast-deep-equal';
    import shallow from 'zustand/shallow';
    
    // Using deep equality
    const useStoreDeep = create<StoreState>()(
      temporal((set) => ({ /* ... */ }), { equality: isDeepEqual })
    );
    
    // Using shallow equality
    const useStoreShallow = create<StoreState>()(
      temporal((set) => ({ /* ... */ }), { equality: shallow })
    );
    
    // Using custom logic
    const useStoreCustom = create<StoreState>()(
      temporal((set) => ({ /* ... */ }), {
        equality: (past, current) => past.field1 === current.field1
      })
    );
  6. Create a vanilla store with `temporal` middleware

    main

    To enable undo/redo capabilities, wrap your Zustand store definition with the temporal middleware. This returns a standard Zustand store that also tracks historical states.

    import { create } from 'zustand';
    import { temporal } from 'zundo';
    
    interface StoreState {
      bears: number;
      increasePopulation: () => void;
      removeAllBears: () => void;
    }
    
    const useStoreWithUndo = create<StoreState>()(
      temporal((set) => ({
        bears: 0,
        increasePopulation: () => set((state) => ({ bears: state.bears + 1 })),
        removeAllBears: () => set({ bears: 0 }),
      })),
    );
  7. Implement a cool-off period with `handleSet`

    main

    If your application triggers many state changes in rapid succession (e.g., during a drag operation), you can use handleSet to throttle or debounce the history recording. handleSet provides a wrapped version of the store's setState method that you can pass through a utility like lodash.throttle or lodash.debounce.

    import { throttle } from 'lodash'; // or similar
    
    const useStoreWithUndo = create<StoreState>()(
      temporal(
        (set) => ({ /* ... */ }),
        {
          handleSet: (handleSet) =>
            throttle<typeof handleSet>((state) => {
              handleSet(state);
            }, 1000),
        },
      ),
    );
  8. Exclude or include specific fields in history using `partialize`

    main

    By default, the entire state object is tracked. Use the partialize option to provide a callback that returns only the fields you want to include in the history. This is useful for excluding large or non-essential fields from the undo/redo buffer.

    Note for TypeScript users: When using useTemporalStore (the React hook wrapper) with partialize, you must explicitly define the type of your partialized state to ensure type safety.

    // Only field1 and field2 will be tracked
    const useStoreWithUndoA = create<StoreState>()(
      temporal(
        (set) => ({ /* your store fields */ }),
        {
          partialize: (state) => {
            const { field1, field2, ...rest } = state;
            return { field1, field2 };
          },
        },
      ),
    );
    
    // TypeScript pattern for React hooks with partialized state
    interface StoreState {
      bears: number;
      untrackedStateField: number;
    }
    
    type PartializedStoreState = Pick<StoreState, 'bears'>;
    
    const useStoreWithUndo = create<StoreState>()(
      temporal(
        (set) => ({ bears: 0, untrackedStateField: 0 }),
        { partialize: (state) => ({ bears: state.bears }) }
      ),
    );
    
    const useTemporalStore = <T,>( 
      selector: (state: TemporalState<PartializedStoreState>) => T,
    ) => useStore(useStoreWithUndo.temporal, selector);
  9. Limit the number of historical states with `limit`

    main

    To optimize performance and memory usage, you can set a limit in the ZundoOptions. This restricts the number of previous and future states stored in the temporal history. When the limit is reached, the oldest state is dropped.

    const useStoreWithUndo = create<StoreState>()(
      temporal(
        (set) => ({ /* your store fields */ }),
        { limit: 100 },
      ),
    );
  10. Access `temporal` functions and properties

    main

    Once a store is created with temporal middleware, it has an attached temporal object. You can access time-travel utilities like undo, redo, and clear using useStore.temporal.getState().

    Note: Properties like pastStates and futureStates are not reactive when accessed directly via getState().

    const { undo, redo, clear } = useStoreWithUndo.temporal.getState();
    
    // Usage in event handlers:
    // <button onClick={() => undo()}>undo</button>