Package Exports
- zundo
- zundo/dist/index.js
This package does not declare an exports field, so the exports above have been automatically detected and optimized by JSPM instead. If any package subpath is missing, it is recommended to post an issue to the original package (zundo) to support the "exports" field. If that is not possible, create a JSPM override to customize the exports field for this package.
Readme
π Zundo
enable time-travel in your apps. undo/redo middleware for zustand. built with zustand. <1kB
See a demo
Install
npm i zustand zundo@betazustand v4.1.0 or higher is required for TS usage. v4.0.0 or higher is required for JS usage.
Background
- Solves the issue of managing state in complex user applications
- Leverages zustand for state management, keeping the internals small
- Middleware can be used with multiple stores in the same app
First create a store with temporal middleware
This returns the familiar store accessible by a hook! But now your store tracks past actions.
import { temporal } from 'zundo'
import create from 'zustand'
// 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 }),
})),
);Then bind your components
Use your store anywhere, including undo, redo, and clear!
const App = () => {
const {
bears,
increasePopulation,
removeAllBears,
} = useStoreWithUndo();
const {
undo,
redo,
clear
} = useStoreWithUndo.temporal.getState();
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>
</>
);
};Middleware Options
interface ZundoOptions<State, TemporalState = State> {
partialize?: (state: State) => TemporalState;
limit?: number;
equality?: (a: State, b: State) => boolean;
onSave?: onSave<State>;
handleSet?: (
handleSet: StoreApi<State>['setState'],
) => StoreApi<State>['setState'];
}Exclude fields from being tracked in history
Use the partialize option, which takes a callback where you can choose to omit or include specific fields.
// Only field1 and field2 will be tracked
const useStoreA = create<StoreState>(
temporal(
set => ({ ... }),
{ partialize: (state) => {
const { field1, field2, ...rest } = state
return { field1, field2 }
}}
)
)
// Everything besides field1 and field2 will be tracked
const useStoreB = create<StoreState>(
temporal(
set => ({ ... }),
{ partialize: (state) => {
const { field1, field2, ...rest } = state
return rest;
}}
)
)Limit number of states stored
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. By default, no limit is set.
const useStore = create<StoreState>(
temporal(
set => ({ ... }),
{ limit: 100 }
)
);Prevent unchanged states to be stored
For performance reasons, you may want to use a custom equality function to determine when a state change should be tracked. By default, all state changes to your store are tracked. You can write your own or use something like lodash/deepEqual or zustand/shallow.
import shallow from 'zustand/shallow'
const useStoreA = create<StoreState>(
temporal(
set => ({ ... }),
{ equality: shallow }
)
);
const useStoreB = create<StoreState>(
temporal(
set => ({ ... }),
{ equality: (a, b) => a.field1 !== b.field1 }
)
);Callback when temporal store is updated
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.
import shallow from 'zustand/shallow'
const useStoreA = create<StoreState>(
temporal(
set => ({ ... }),
{ onSave: (state) => console.log('saved', state) }
)
);Cool-off period
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.
const withTemporal = temporal<MyState>(
(set) => ({ ... }),
{
handleSet: (handleSet) =>
throttle<typeof handleSet>((state) => {
console.info('handleSet called');
handleSet(state);
}, 1000),
},
);useStore.temporal
Temporal is a vanilla zustand store: see StoreApi
getStatereturns the current state of the temporal store.setStateupdates the state of the temporal store.subscribesubscribes to changes in the temporal store.destroydestroys the temporal store.
useStore.temporal.getState
interface TemporalState<TState> {
pastStates: TState[];
futureStates: TState[];
undo: (steps?: number) => void;
redo: (steps?: number) => void;
clear: () => void;
trackingState: 'paused' | 'tracking';
pause: () => void;
resume: () => void;
setOnSave: (onSave: onSave<TState>) => void;
}Going back in time
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 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: call function to apply previous state (if there are previous states). Optionally pass a number of steps to undo.
Take it back now y'all
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.
Remove all knowledge of time
clear: call function to remove all stored states from your undo store. Warning: clearing cannot be undone.
Dispatching a new state will clear all of the future states.
Stop and start history
trackingState: returns a string that indicates whether the temporal store is tracking state changes or not. Possible values are'paused'or'tracking'. To pause and resume tracking, usepauseandresume.
Pause tracking of history
pause: call function to pause tracking state changes. This will prevent new states from being stored in history.
Resume tracking of history
resume: call function to resume tracking state changes. This will allow new states to be stored in history.
Programmatically add middleware to the setter
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.
Road Map
- create nicer API, or a helper hook in react land (useTemporal). or vanilla version of the it
- Allow alternative storage engines for past and future states
- See if it canUndo and canRedo
- Set initial history, past and future states
- create
jumpmethod that takes an integer - create a
presentobject that holds the current state? perhaps - support history branches rather than clearing the future states
- Pass middleware to temporal store
- store state delta rather than full object
- track state for multiple stores at once
Contributing
PRs are welcome! pnpm is used as a package manager. Run pnpm install to install local dependencies. Library code is located at packages/zundo.
Author
- Charles Kornoelje (@_charkour) - Tekton
Versioning
View the releases for the change log.
Illustration Credits
- Ivo IliΔ (@theivoson)