Gathering detailed insights and metrics for zundo
NPM was acquired by GitHub in March 2020.
Gathering detailed insights and metrics for zundo
NPM was acquired by GitHub in March 2020.
npm install zundo
94.5
Supply Chain
100
Quality
88
Maintenance
100
Vulnerability
100
License
v2.3.0 - zustand v5 support
Published on 17 Nov 2024
v2.2.0 - TypeScript Bundling Updates
Published on 03 Oct 2024
v2.1.0 - Update Handle Set arguments
Published on 21 Jan 2024
v2.0.3 - TS Fix and Docs
Published on 23 Dec 2023
v2.0.2 - Actually support ESM and CJS
Published on 23 Dec 2023
v2.0.1 - CJS (and ESM) and Deno
Published on 09 Dec 2023
639 Stars
252 Commits
21 Forks
7 Watching
4 Branches
7 Contributors
Updated on 20 Nov 2024
Minified
Minified + Gzipped
TypeScript (100%)
Cumulative downloads
Total Downloads
Last day
12.3%
3,487
Compared to previous day
Last week
12.5%
19,164
Compared to previous week
Last month
26.6%
75,667
Compared to previous month
Last year
186%
488,257
Compared to previous year
1
4
enable time-travel in your apps. undo/redo middleware for zustand. built with zustand. <700 B
Try a live demo
1npm i zustand zundo
zustand v4.2.0+ or v5 is required for TS usage. v4.0.0 or higher is required for JS usage. Node 16 or higher is required.
temporal
middlewareThis returns the familiar store accessible by a hook! But now your store also tracks past states.
1import { create } from 'zustand'; 2import { temporal } from 'zundo'; 3 4// Define the type of your store state (typescript) 5interface StoreState { 6 bears: number; 7 increasePopulation: () => void; 8 removeAllBears: () => void; 9} 10 11// Use `temporal` middleware to create a store with undo/redo capabilities 12const useStoreWithUndo = create<StoreState>()( 13 temporal((set) => ({ 14 bears: 0, 15 increasePopulation: () => set((state) => ({ bears: state.bears + 1 })), 16 removeAllBears: () => set({ bears: 0 }), 17 })), 18);
temporal
functions and properties of your storeYour zustand store will now have an attached temporal
object that provides access to useful time-travel utilities, including undo
, redo
, and clear
!
1const App = () => { 2 const { bears, increasePopulation, removeAllBears } = useStoreWithUndo(); 3 // See API section for temporal.getState() for all functions and 4 // properties provided by `temporal`, but note that properties, such as `pastStates` and `futureStates`, are not reactive when accessed directly from the store. 5 const { undo, redo, clear } = useStoreWithUndo.temporal.getState(); 6 7 return ( 8 <> 9 bears: {bears} 10 <button onClick={() => increasePopulation}>increase</button> 11 <button onClick={() => removeAllBears}>remove</button> 12 <button onClick={() => undo()}>undo</button> 13 <button onClick={() => redo()}>redo</button> 14 <button onClick={() => clear()}>clear</button> 15 </> 16 ); 17};
temporal
object, optionally convert to a React store hookIn React, to subscribe components or custom hooks to member properties of the temporal
object (like the array of pastStates
or currentStates
), you can create a useTemporalStore
hook.
1import { useStoreWithEqualityFn } from 'zustand/traditional'; 2import type { TemporalState } from 'zundo'; 3 4function useTemporalStore(): TemporalState<MyState>; 5function useTemporalStore<T>(selector: (state: TemporalState<MyState>) => T): T; 6function useTemporalStore<T>( 7 selector: (state: TemporalState<MyState>) => T, 8 equality: (a: T, b: T) => boolean, 9): T; 10function useTemporalStore<T>( 11 selector?: (state: TemporalState<MyState>) => T, 12 equality?: (a: T, b: T) => boolean, 13) { 14 return useStoreWithEqualityFn(useStoreWithUndo.temporal, selector!, equality); 15} 16 17const App = () => { 18 const { bears, increasePopulation, removeAllBears } = useStoreWithUndo(); 19 // changes to pastStates and futureStates will now trigger a reactive component rerender 20 const { undo, redo, clear, pastStates, futureStates } = useTemporalStore( 21 (state) => state, 22 ); 23 24 return ( 25 <> 26 <p> bears: {bears}</p> 27 <p> pastStates: {JSON.stringify(pastStates)}</p> 28 <p> futureStates: {JSON.stringify(futureStates)}</p> 29 <button onClick={() => increasePopulation}>increase</button> 30 <button onClick={() => removeAllBears}>remove</button> 31 <button onClick={() => undo()}>undo</button> 32 <button onClick={() => redo()}>redo</button> 33 <button onClick={() => clear()}>clear</button> 34 </> 35 ); 36};
(config: StateCreator, options?: ZundoOptions) => StateCreator
zundo
has one export: temporal
. It is used 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.
1export interface ZundoOptions<TState, PartialTState = TState> { 2 partialize?: (state: TState) => PartialTState; 3 limit?: number; 4 equality?: (pastState: PartialTState, currentState: PartialTState) => boolean; 5 diff?: ( 6 pastState: Partial<PartialTState>, 7 currentState: Partial<PartialTState>, 8 ) => Partial<PartialTState> | null; 9 onSave?: (pastState: TState, currentState: TState) => void; 10 handleSet?: ( 11 handleSet: StoreApi<TState>['setState'], 12 ) => StoreApi<TState>['setState']; 13 pastStates?: Partial<PartialTState>[]; 14 futureStates?: Partial<PartialTState>[]; 15 wrapTemporal?: ( 16 storeInitializer: StateCreator< 17 _TemporalState<TState>, 18 [StoreMutatorIdentifier, unknown][], 19 [] 20 >, 21 ) => StateCreator< 22 _TemporalState<TState>, 23 [StoreMutatorIdentifier, unknown][], 24 [StoreMutatorIdentifier, unknown][] 25 >; 26}
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.
1// Only field1 and field2 will be tracked 2const useStoreWithUndoA = create<StoreState>()( 3 temporal( 4 (set) => ({ 5 // your store fields 6 }), 7 { 8 partialize: (state) => { 9 const { field1, field2, ...rest } = state; 10 return { field1, field2 }; 11 }, 12 }, 13 ), 14); 15 16// Everything besides field1 and field2 will be tracked 17const useStoreWithUndoB = create<StoreState>()( 18 temporal( 19 (set) => ({ 20 // your store fields 21 }), 22 { 23 partialize: (state) => { 24 const { field1, field2, ...rest } = state; 25 return rest; 26 }, 27 }, 28 ), 29);
useTemporalStore
with partialize
If converting temporal store to a React Store Hook with typescript, be sure to define the type of your partialized state
1interface StoreState { 2 bears: number; 3 untrackedStateField: number; 4} 5 6type PartializedStoreState = Pick<StoreState, 'bears'>; 7 8const useStoreWithUndo = create<StoreState>()( 9 temporal( 10 (set) => ({ 11 bears: 0, 12 untrackedStateField: 0, 13 }), 14 { 15 partialize: (state) => { 16 const { bears } = state; 17 return { bears }; 18 }, 19 }, 20 ), 21); 22 23const useTemporalStore = <T,>( 24 // Use partalized StoreState type as the generic here 25 selector: (state: TemporalState<PartializedStoreState>) => T, 26) => useStore(useStoreWithUndo.temporal, selector);
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.
1const useStoreWithUndo = create<StoreState>()( 2 temporal( 3 (set) => ({ 4 // your store fields 5 }), 6 { limit: 100 }, 7 ), 8);
equality?: (pastState: PartialTState, currentState: PartialTState) => boolean
By default, a state snapshot is stored in temporal
history when any zustand
state setter is called—even if no value in your zustand
store has changed.
If all of your zustand
state setters modify state in a way that you want tracked in history, this default is sufficient.
However, for more precise control over when a state snapshot is stored in zundo
history, you can provide an equality
function.
You can write your own equality function or use something like fast-equals
, fast-deep-equal
, zustand/shallow
, lodash.isequal
, or underscore.isEqual
.
1import isDeepEqual from 'fast-deep-equal'; 2 3// Use a deep equality function to only store history when currentState has changed 4const useStoreWithUndo = create<StoreState>()( 5 temporal( 6 (set) => ({ 7 // your store fields 8 }), 9 // a state snapshot will only be stored in history when currentState is not deep-equal to pastState 10 // Note: this can also be more concisely written as {equality: isDeepEqual} 11 { 12 equality: (pastState, currentState) => 13 isDeepEqual(pastState, currentState), 14 }, 15 ), 16);
If your state or specific application does not require deep equality (for example, if you're only using non-nested primitives), you may for performance reasons choose to use a shallow equality fn that does not do deep comparison.
1import shallow from 'zustand/shallow'; 2 3const useStoreWithUndo = create<StoreState>()( 4 temporal( 5 (set) => ({ 6 // your store fields 7 }), 8 // a state snapshot will only be stored in history when currentState is not deep-equal to pastState 9 // Note: this can also be more concisely written as {equality: shallow} 10 { 11 equality: (pastState, currentState) => shallow(pastState, currentState), 12 }, 13 ), 14);
You can also just as easily use custom equality functions for your specific application
1const useStoreWithUndo = create<StoreState>()( 2 temporal( 3 (set) => ({ 4 // your store fields 5 }), 6 { 7 // Only track history when field1 AND field2 diverge from their pastState 8 // Why would you do this? I don't know! But you can do it! 9 equality: (pastState, currentState) => 10 pastState.field1 !== currentState.field1 && 11 pastState.field2 !== currentState.field2, 12 }, 13 ), 14);
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
.
1const useStoreWithUndo = create<StoreState>()( 2 temporal( 3 (set) => ({ 4 // your store fields 5 }), 6 { 7 diff: (pastState, currentState) => { 8 const myDiff = diff(currentState, pastState); 9 const newStateFromDiff = myDiff.reduce( 10 (acc, difference) => { 11 type Key = keyof typeof currentState; 12 if (difference.type === 'CHANGE') { 13 const pathAsString = difference.path.join('.') as Key; 14 acc[pathAsString] = difference.value; 15 } 16 return acc; 17 }, 18 {} as Partial<typeof currentState>, 19 ); 20 return isEmpty(newStateFromDiff) ? null : newStateFromDiff; 21 }, 22 }, 23 ), 24);
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).
1import { shallow } from 'zustand/shallow'; 2 3const useStoreWithUndo = create<StoreState>()( 4 temporal( 5 (set) => ({ 6 // your store fields 7 }), 8 { onSave: (state) => console.log('saved', state) }, 9 ), 10);
1 handleSet?: (handleSet: StoreApi<TState>['setState']) => ( 2 pastState: Parameters<StoreApi<TState>['setState']>[0], 3 // `replace` will likely be deprecated and removed in the future 4 replace: Parameters<StoreApi<TState>['setState']>[1], 5 currentState: PartialTState, 6 deltaState?: Partial<PartialTState> | null, 7) => void
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.
1const useStoreWithUndo = create<StoreState>()( 2 temporal( 3 (set) => ({ 4 // your store fields 5 }), 6 { 7 handleSet: (handleSet) => 8 throttle<typeof handleSet>((state) => { 9 console.info('handleSet called'); 10 handleSet(state); 11 }, 1000), 12 }, 13 ), 14);
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
andfutureStates
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.
1const useStoreWithUndo = create<StoreState>()( 2 temporal( 3 (set) => ({ 4 // your store fields 5 }), 6 { 7 pastStates: [{ field1: 'value1' }, { field1: 'value2' }], 8 futureStates: [{ field1: 'value3' }, { field1: 'value4' }], 9 }, 10 ), 11);
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 thetemporal
store. This way, you could track the history of the history. 🤯
1import { persist } from 'zustand/middleware'; 2 3const useStoreWithUndo = create<StoreState>()( 4 temporal( 5 (set) => ({ 6 // your store fields 7 }), 8 { 9 wrapTemporal: (storeInitializer) => 10 persist(storeInitializer, { name: 'temporal-persist' }), 11 }, 12 ), 13);
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
Use temporal.getState()
to access to temporal store!
While
setState
,subscribe
, anddestroy
exist ontemporal
, you should not need to use them.
useStore.temporal.getState()
temporal.getState()
returns the TemporalState
which contains undo
, redo
, and other helpful functions and fields.
1interface TemporalState<TState> { 2 pastStates: TState[]; 3 futureStates: TState[]; 4 5 undo: (steps?: number) => void; 6 redo: (steps?: number) => void; 7 clear: () => void; 8 9 isTracking: boolean; 10 pause: () => void; 11 resume: () => void; 12 13 setOnSave: (onSave: onSave<TState>) => void; 14}
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.
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."
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.
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.
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.
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: () => 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: () => 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
.
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.
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!
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.
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.temporal
rather than undoMiddleware
.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.temporal.getState()
APIundo
, 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.1- import { undoMiddleware } from 'zundo'; 2+ import { temporal } from 'zundo';
include
or exclude
, use the new partialize
option1// v1.6.0 2// Only field1 and field2 will be tracked 3const useStoreA = create<StoreState>()( 4 undoMiddleware( 5 set => ({ ... }), 6 { include: ['field1', 'field2'] } 7 ) 8); 9 10// Everything besides field1 and field2 will be tracked 11const useStoreB = create<StoreState>()( 12 undoMiddleware( 13 set => ({ ... }), 14 { exclude: ['field1', 'field2'] } 15 ) 16); 17 18// v2.0.0 19// Only field1 and field2 will be tracked 20const useStoreA = create<StoreState>()( 21 temporal( 22 (set) => ({ 23 // your store fields 24 }), 25 { 26 partialize: (state) => { 27 const { field1, field2, ...rest } = state; 28 return { field1, field2 }; 29 }, 30 }, 31 ), 32); 33 34// Everything besides field1 and field2 will be tracked 35const useStoreB = create<StoreState>()( 36 temporal( 37 (set) => ({ 38 // your store fields 39 }), 40 { 41 partialize: (state) => { 42 const { field1, field2, ...rest } = state; 43 return rest; 44 }, 45 }, 46 ), 47);
allowUnchanged
, use the new equality
option1// v1.6.0 2// Use an existing `allowUnchanged` option 3const useStore = create<StoreState>()( 4 undoMiddleware( 5 set => ({ ... }), 6 { allowUnchanged: true } 7 ) 8); 9 10// v2.0.0 11// Use an existing equality function 12import { shallow } from 'zustand/shallow'; // or use `lodash.isequal` or any other equality function 13 14// Use an existing equality function 15const useStoreA = create<StoreState>()( 16 temporal( 17 (set) => ({ 18 // your store fields 19 }), 20 { equality: shallow }, 21 ), 22);
historyDepthLimit
, use the new limit
option1// v1.6.0 2// Use an existing `historyDepthLimit` option 3const useStore = create<StoreState>()( 4 undoMiddleware( 5 set => ({ ... }), 6 { historyDepthLimit: 100 } 7 ) 8); 9 10// v2.0.0 11// Use `limit` option 12const useStore = create<StoreState>()( 13 temporal( 14 (set) => ({ 15 // your store fields 16 }), 17 { limit: 100 }, 18 ), 19);
coolOffDurationMs
, use the new handleSet
option1// v1.6.0 2// Use an existing `coolOffDurationMs` option 3const useStore = create<StoreState>()( 4 undoMiddleware( 5 set => ({ ... }), 6 { coolOfDurationMs: 1000 } 7 ) 8); 9 10// v2.0.0 11// Use `handleSet` option 12const withTemporal = temporal<MyState>( 13 (set) => ({ 14 // your store fields 15 }), 16 { 17 handleSet: (handleSet) => 18 throttle<typeof handleSet>((state) => { 19 console.info('handleSet called'); 20 handleSet(state); 21 }, 1000), 22 }, 23);
Charles Kornoelje (@_charkour)
View the releases for the change log. This project follows semantic versioning.
Ivo Ilić (@theivoson)
No vulnerabilities found.
Reason
no dangerous workflow patterns detected
Reason
9 commit(s) and 3 issue activity found in the last 90 days -- score normalized to 10
Reason
no binaries found in the repo
Reason
license file detected
Details
Reason
0 existing vulnerabilities detected
Reason
Found 2/30 approved changesets -- score normalized to 0
Reason
detected GitHub workflow tokens with excessive permissions
Details
Reason
dependency not pinned by hash detected -- score normalized to 0
Details
Reason
no effort to earn an OpenSSF best practices badge detected
Reason
security policy file not detected
Details
Reason
project is not fuzzed
Details
Reason
SAST tool is not run on all commits -- score normalized to 0
Details
Score
Last Scanned on 2024-11-18
The Open Source Security Foundation is a cross-industry collaboration to improve the security of open source software (OSS). The Scorecard provides security health metrics for open source projects.
Learn More