From 52f90e703dd4816dbdc903ef04667657aebe1408 Mon Sep 17 00:00:00 2001 From: Patrick Strassmann Date: Sat, 23 Dec 2023 07:58:29 -0800 Subject: [PATCH] fix equality fn type and update docs (#144) Co-authored-by: Patrick Strassmann --- README.md | 280 +++++++++++++++++++++++++++++++++------------------ src/types.ts | 2 +- 2 files changed, 184 insertions(+), 98 deletions(-) diff --git a/README.md b/README.md index 8e9adc2..1d0d819 100644 --- a/README.md +++ b/README.md @@ -33,22 +33,22 @@ npm i zustand zundo 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 +## First create a vanilla store with `temporal` middleware -This returns the familiar store accessible by a hook! But now your store tracks past actions. +This returns the familiar store accessible by a hook! But now your store also tracks past states. ```tsx import { create } from 'zustand'; import { temporal } from 'zundo'; -// define the store (typescript) +// Define the type of your store state (typescript) interface StoreState { bears: number; increasePopulation: () => void; removeAllBears: () => void; } -// creates a store with undo/redo capability +// Use `temporal` middleware to create a store with undo/redo capabilities const useStoreWithUndo = create()( temporal((set) => ({ bears: 0, @@ -58,9 +58,35 @@ const useStoreWithUndo = create()( ); ``` -## Convert to React Store +## Then access `temporal` functions and properties of your store -If you're using React, you can convert the store to a React hook using create from `zustand`. +Your zustand store will now have an attached `temporal` object that +provides access to useful time-travel utilities, including `undo`, `redo`, and `clear`! + +```tsx +const App = () => { + const { bears, increasePopulation, removeAllBears } = useStoreWithUndo(); + // See API section for temporal.getState() for all functions and + // properties provided by `temporal` + const { undo, redo, clear } = useStoreWithUndo.temporal.getState(); + + return ( + <> + bears: {bears} + + + + + + + ); +}; +``` + +## For reactive changes to member properties of the `temporal` object, optionally convert to a React store hook + +In 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. ```tsx import { useStore } from 'zustand'; @@ -69,24 +95,20 @@ import type { TemporalState } from 'zundo'; const useTemporalStore = ( selector: (state: TemporalState) => 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`! +) => useStore(useStoreWithUndo.temporal, selector, equality); -```tsx 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. + // changes to pastStates and futureStates will now trigger a reactive component rerender + const { undo, redo, clear, pastStates, futureStates } = useTemporalStore( + (state) => state, + ); return ( <> - bears: {bears} +

bears: {bears}

+

pastStates: {JSON.stringify(pastStates)}

+

futureStates: {JSON.stringify(futureStates)}

@@ -103,24 +125,20 @@ const App = () => { `(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. +`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. -### Middleware Options +### Bear's eye view: ```tsx -type onSave = - | ((pastState: TState, currentState: TState) => void) - | undefined; - export interface ZundoOptions { partialize?: (state: TState) => PartialTState; limit?: number; - equality?: (pastState: TState, currentState: TState) => boolean; + equality?: (pastState: PartialTState, currentState: PartialTState) => boolean; diff?: ( pastState: Partial, currentState: Partial, ) => Partial | null; - onSave?: onSave; + onSave?: (pastState: TState, currentState: TState) => void; handleSet?: ( handleSet: StoreApi['setState'], ) => StoreApi['setState']; @@ -140,7 +158,7 @@ export interface ZundoOptions { } ``` -#### **Exclude fields from being tracked in history** +### Exclude fields from being tracked in history `partialize?: (state: TState) => PartialTState` @@ -148,7 +166,7 @@ Use the `partialize` option to omit or include specific fields. Pass a callback ```tsx // Only field1 and field2 will be tracked -const useStoreA = create( +const useStoreWithUndoA = create( temporal( (set) => ({ // your store fields @@ -163,7 +181,7 @@ const useStoreA = create( ); // Everything besides field1 and field2 will be tracked -const useStoreB = create( +const useStoreWithUndoB = create( temporal( (set) => ({ // your store fields @@ -178,14 +196,49 @@ const useStoreB = create( ); ``` -#### **Limit number of states stored** +#### `useTemporalStore` with `partialize` + +If converting temporal store to a React Store Hook with typescript, be sure to define the type of +your partialized state + +```tsx +interface StoreState { + bears: number; + untrackedStateField: number; +} + +type PartializedStoreState = Pick; + +const useStoreWithUndo = create( + temporal( + (set) => ({ + bears: 0, + untrackedStateField: 0, + }), + { + partialize: (state) => { + const { bears } = state; + return { bears }; + }, + }, + ), +); + +const useTemporalStore = ( + // Use partalized StoreState type as the generic here + selector: (state: TemporalState) => T, + equality?: (a: T, b: T) => boolean, +) => useStore(useStoreWithUndo.temporal, selector, equality); +``` + +### Limit number of historical 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. ```tsx -const useStore = create( +const useStoreWithUndo = create( temporal( (set) => ({ // your store fields @@ -195,37 +248,86 @@ const useStore = create( ); ``` -#### **Prevent unchanged states to be stored** +### Prevent unchanged states from getting stored in history + +`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. -`equality?: (pastState: TState, currentState: TState) => boolean` +You can write your own equality function or use something like [`fast-equals`](https://github.com/planttheidea/fast-equals), [`fast-deep-equal`](https://github.com/epoberezkin/fast-deep-equal), [`zustand/shallow`](https://github.com/pmndrs/zustand/blob/main/src/shallow.ts), [`lodash.isequal`](https://www.npmjs.com/package/lodash.isequal), or [`underscore.isEqual`](https://github.com/jashkenas/underscore/blob/master/modules/isEqual.js). -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`](https://github.com/planttheidea/fast-equals), [`fast-deep-equal`](https://github.com/epoberezkin/fast-deep-equal), [`zustand/shallow`](https://github.com/pmndrs/zustand/blob/main/src/shallow.ts), [`lodash.isequal`](https://www.npmjs.com/package/lodash.isequal), or [`underscore.isEqual`](https://github.com/jashkenas/underscore/blob/master/modules/isEqual.js). By default, all state changes to your store are tracked. +#### Example with deep equality ```tsx -import { shallow } from 'zustand/shallow'; +import isDeepEqual from 'fast-deep-equal'; -// Use an existing equality function -const useStoreA = create( +// Use a deep equality function to only store history when currentState has changed +const useStoreWithUndo = create( temporal( (set) => ({ // your store fields }), - { equality: shallow }, + // a state snapshot will only be stored in history when currentState is not deep-equal to pastState + // Note: this can also be more concisely written as {equality: isDeepEqual} + { + equality: (pastState, currentState) => + isDeepEqual(pastState, currentState), + }, ), ); +``` -// Write your own equality function -const useStoreB = create( +#### Example with shallow equality + +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. + +```tsx +import shallow from 'zustand/shallow'; + +const useStoreWithUndo = create( temporal( (set) => ({ // your store fields }), - { equality: (a, b) => a.field1 !== b.field1 }, + // a state snapshot will only be stored in history when currentState is not deep-equal to pastState + // Note: this can also be more concisely written as {equality: shallow} + { + equality: (pastState, currentState) => shallow(pastState, currentState), + }, ), ); ``` -#### **Store state delta rather than full object** +#### Example with custom equality + +You can also just as easily use custom equality functions for your specific application + +```tsx +const useStoreWithUndo = create( + temporal( + (set) => ({ + // your store fields + }), + { + // Only track history when field1 AND field2 diverge from their pastState + // Why would you do this? I don't know! But you can do it! + equality: (pastState, currentState) => + pastState.field1 !== currentState.field1 && + pastState.field2 !== currentState.field2, + }, + ), +); +``` + +### Store state delta rather than full object `diff?: (pastState: Partial, currentState: Partial) => Partial | null` @@ -236,7 +338,7 @@ If `diff` returns `null`, the state change will not be tracked. This is helpful You can write your own or use something like [`microdiff`](https://github.com/AsyncBanana/microdiff), [`just-diff`](https://github.com/angus-c/just/tree/master/packages/collection-diff), or [`deep-object-diff`](https://github.com/mattphillips/deep-object-diff). ```tsx -const useStore = create( +const useStoreWithUndo = create( temporal( (set) => ({ // your store fields @@ -262,7 +364,7 @@ const useStore = create( ); ``` -#### **Callback when temporal store is updated** +### Callback when temporal store is updated `onSave?: (pastState: TState, currentState: TState) => void` @@ -271,7 +373,7 @@ Sometimes, you may need to call a function when the temporal store is updated. T ```tsx import { shallow } from 'zustand/shallow'; -const useStoreA = create( +const useStoreWithUndo = create( temporal( (set) => ({ // your store fields @@ -281,28 +383,30 @@ const useStoreA = create( ); ``` -#### **Cool-off period** +### Cool-off period `handleSet?: (handleSet: StoreApi['setState']) => StoreApi['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`](https://github.com/niksy/throttle-debounce), [`just-throttle`](https://github.com/angus-c/just/tree/master/packages/function-throttle), [`just-debounce-it`](https://github.com/angus-c/just/tree/master/packages/function-debounce), [`lodash.throttle`](https://www.npmjs.com/package/lodash.throttle), or [`lodash.debounce`](https://www.npmjs.com/package/lodash.debounce). This a way to provide middleware to the temporal store's setter function. ```tsx -const withTemporal = temporal( - (set) => ({ - // your store fields - }), - { - handleSet: (handleSet) => - throttle((state) => { - console.info('handleSet called'); - handleSet(state); - }, 1000), - }, +const useStoreWithUndo = create( + temporal( + (set) => ({ + // your store fields + }), + { + handleSet: (handleSet) => + throttle((state) => { + console.info('handleSet called'); + handleSet(state); + }, 1000), + }, + ), ); ``` -#### **Initialize temporal store with past and future states** +### Initialize temporal store with past and future states `pastStates?: Partial[]` @@ -313,18 +417,20 @@ You can initialize the temporal store with past and future states. This is usefu > 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. ```tsx -const withTemporal = temporal( - (set) => ({ - // your store fields - }), - { - pastStates: [{ field1: 'value1' }, { field1: 'value2' }], - futureStates: [{ field1: 'value3' }, { field1: 'value4' }], - }, +const useStoreWithUndo = create( + temporal( + (set) => ({ + // your store fields + }), + { + pastStates: [{ field1: 'value1' }, { field1: 'value2' }], + futureStates: [{ field1: 'value3' }, { field1: 'value4' }], + }, + ), ); ``` -#### **Wrap temporal store** +### Wrap temporal store `wrapTemporal?: (storeInitializer: StateCreator<_TemporalState, [StoreMutatorIdentifier, unknown][], []>) => StateCreator<_TemporalState, [StoreMutatorIdentifier, unknown][], [StoreMutatorIdentifier, unknown][]>` @@ -337,14 +443,16 @@ For a full list of middleware, see [zustand middleware](https://www.npmjs.com/pa ```tsx import { persist } from 'zustand/middleware'; -const withTemporal = temporal( - (set) => ({ - // your store fields - }), - { - wrapTemporal: (storeInitializer) => - persist(storeInitializer, { name: 'temporal-persist' }), - }, +const useStoreWithUndo = create( + temporal( + (set) => ({ + // your store fields + }), + { + wrapTemporal: (storeInitializer) => + persist(storeInitializer, { name: 'temporal-persist' }), + }, + ), ); ``` @@ -356,28 +464,6 @@ 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`. - -```tsx -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. diff --git a/src/types.ts b/src/types.ts index ce6c4d5..840e956 100644 --- a/src/types.ts +++ b/src/types.ts @@ -24,7 +24,7 @@ export interface _TemporalState { export interface ZundoOptions { partialize?: (state: TState) => PartialTState; limit?: number; - equality?: (pastState: TState, currentState: TState) => boolean; + equality?: (pastState: PartialTState, currentState: PartialTState) => boolean; diff?: ( pastState: Partial, currentState: Partial,