├── .eslintrc.json
├── .github
├── FUNDING.yml
└── workflows
│ ├── build.yml
│ └── coverage.yml
├── .gitignore
├── .npmrc
├── .prettierignore
├── .prettierrc.json
├── .vscode
└── settings.json
├── CONTRIBUTING.md
├── LICENSE
├── README.md
├── examples
└── web
│ ├── .eslintrc.json
│ ├── README.md
│ ├── next-env.d.ts
│ ├── next.config.mjs
│ ├── package.json
│ ├── pages
│ ├── chained.tsx
│ ├── diff.tsx
│ ├── index.tsx
│ ├── input.tsx
│ ├── nested.tsx
│ ├── options.tsx
│ ├── persist.tsx
│ ├── reactive.tsx
│ ├── separate.tsx
│ ├── setstate.tsx
│ ├── slices.tsx
│ ├── subscribeWithSelector.tsx
│ ├── throttle.tsx
│ └── wrapped.tsx
│ └── tsconfig.json
├── package.json
├── pnpm-lock.yaml
├── pnpm-workspace.yaml
├── size
└── package.json
├── src
├── index.ts
├── temporal.ts
└── types.ts
├── tests
├── __mocks__
│ └── zustand
│ │ └── index.ts
├── __tests__
│ ├── createVanillaTemporal.test.ts
│ ├── options.test.ts
│ ├── react.test.tsx
│ └── zundo.test.ts
├── package.json
├── tsconfig.json
├── vitest.config.ts
└── vitest.setup.ts
├── tsconfig.json
├── tsup.config.ts
└── zundo-mascot.png
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "root": true,
3 | "files": [
4 | "*.ts",
5 | "*.mjs",
6 | "*.js"
7 | ]
8 | }
9 |
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | github: [charkour]
2 |
--------------------------------------------------------------------------------
/.github/workflows/build.yml:
--------------------------------------------------------------------------------
1 | name: build
2 |
3 | on:
4 | pull_request:
5 |
6 | jobs:
7 | build:
8 | runs-on: ubuntu-latest
9 |
10 | strategy:
11 | matrix:
12 | node-version: [18.x, 23.x]
13 | zustand: [
14 | 4.2.0, # Oldest zustand TS supported
15 | 4.0.0, # Oldest zustand JS supported
16 | 4, # Latest zustand v5 supported
17 | latest # Latest zustand supported
18 | ]
19 |
20 | steps:
21 | - uses: actions/checkout@v4
22 |
23 | - name: Use Node.js ${{ matrix.node-version }}
24 | uses: actions/setup-node@v4
25 | with:
26 | node-version: ${{ matrix.node-version }}
27 |
28 | - uses: pnpm/action-setup@v4
29 | name: Install pnpm
30 | id: pnpm-install
31 | with:
32 | version: 8
33 | run_install: false
34 |
35 | - name: Install Dependencies
36 | run: pnpm install
37 |
38 | - name: Install Zustand ${{ matrix.zustand }}
39 | run: pnpm add zustand@${{ matrix.zustand }} -w
40 |
41 | - name: Patch tsup config
42 | if: ${{ matrix.zustand == '4.0.0' }}
43 | # Patch the tsup config to use `dts: false` for Zustand 4.0.0
44 | run: |
45 | sed -i~ 's/dts: true/dts: false/' tsup.config.ts
46 |
47 | - name: Build
48 | run: pnpm run build
49 |
50 | - name: Test
51 | run: pnpm run test:ci
52 |
--------------------------------------------------------------------------------
/.github/workflows/coverage.yml:
--------------------------------------------------------------------------------
1 | name: 'Test'
2 | on:
3 | pull_request:
4 |
5 | jobs:
6 | test:
7 | runs-on: ubuntu-latest
8 |
9 | permissions:
10 | # Required to checkout the code
11 | contents: read
12 | # Required to put a comment into the pull-request
13 | pull-requests: write
14 |
15 | steps:
16 | - uses: actions/checkout@v4
17 | - uses: pnpm/action-setup@v4
18 | with:
19 | version: 8
20 | run_install: false
21 | - name: Install Node
22 | uses: actions/setup-node@v4
23 | - name: Install Deps
24 | run: pnpm install
25 | - name: Test
26 | run: pnpm test
27 | - name: Report Coverage
28 | # Set if: always() to also generate the report if tests are failing
29 | # Only works if you set `reportOnFailure: true` in your vite config as specified above
30 | if: always()
31 | uses: davelosert/vitest-coverage-report-action@v2
32 | with:
33 | working-directory: ./tests
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | package-lock.json
3 | dist
4 | coverage
5 | .DS_Store
6 | .next/
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
1 | save-exact=true
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | dist
3 |
--------------------------------------------------------------------------------
/.prettierrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "printWidth": 80,
3 | "tabWidth": 2,
4 | "semi": true,
5 | "singleQuote": true,
6 | "trailingComma": "all",
7 | "quoteProps": "as-needed",
8 | "bracketSpacing": true
9 | }
10 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "cSpell.words": [
3 | "partialize",
4 | "tsup",
5 | "zundo",
6 | "zustand"
7 | ],
8 | "typescript.tsdk": "node_modules/typescript/lib"
9 | }
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing
2 |
3 | Please write out the purpose of the PR and steps to test the PR.
4 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2021 Charles Kornoelje
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # 🍜 Zundo
2 |
3 | enable time-travel in your apps. undo/redo middleware for [zustand](https://github.com/pmndrs/zustand). built with zustand. <700 B
4 |
5 | 
6 |
7 | [](https://bundlephobia.com/result?p=zundo)
8 | [](https://www.npmjs.com/package/zundo)
9 | [](https://www.npmjs.com/package/zundo)
10 |
11 | Try a live [demo](https://codesandbox.io/s/currying-flower-2dom9?file=/src/App.tsx)
12 |
13 | ## Install
14 |
15 | ```sh
16 | npm i zustand zundo
17 | ```
18 |
19 | > zustand v4.2.0+ or v5 is required for TS usage. v4.0.0 or higher is required for JS usage.
20 | > Node 16 or higher is required.
21 |
22 | ## Background
23 |
24 | - Solves the issue of managing state in complex user applications
25 | - "It Just Works" mentality
26 | - Small and fast
27 | - Provides simple middleware to add undo/redo capabilities
28 | - Leverages zustand for state management
29 | - Works with multiple stores in the same app
30 | - Has an unopinionated and extensible API
31 |
32 |
33 |
34 |
35 |
36 | ## First create a vanilla store with `temporal` middleware
37 |
38 | This returns the familiar store accessible by a hook! But now your store also tracks past states.
39 |
40 | ```tsx
41 | import { create } from 'zustand';
42 | import { temporal } from 'zundo';
43 |
44 | // Define the type of your store state (typescript)
45 | interface StoreState {
46 | bears: number;
47 | increasePopulation: () => void;
48 | removeAllBears: () => void;
49 | }
50 |
51 | // Use `temporal` middleware to create a store with undo/redo capabilities
52 | const useStoreWithUndo = create()(
53 | temporal((set) => ({
54 | bears: 0,
55 | increasePopulation: () => set((state) => ({ bears: state.bears + 1 })),
56 | removeAllBears: () => set({ bears: 0 }),
57 | })),
58 | );
59 | ```
60 |
61 | ## Then access `temporal` functions and properties of your store
62 |
63 | Your zustand store will now have an attached `temporal` object that provides access to useful time-travel utilities, including `undo`, `redo`, and `clear`!
64 |
65 | ```tsx
66 | const App = () => {
67 | const { bears, increasePopulation, removeAllBears } = useStoreWithUndo();
68 | // See API section for temporal.getState() for all functions and
69 | // properties provided by `temporal`, but note that properties, such as `pastStates` and `futureStates`, are not reactive when accessed directly from the store.
70 | const { undo, redo, clear } = useStoreWithUndo.temporal.getState();
71 |
72 | return (
73 | <>
74 | bears: {bears}
75 |
76 |
77 |
78 |
79 |
80 | >
81 | );
82 | };
83 | ```
84 |
85 | ## For reactive changes to member properties of the `temporal` object, optionally convert to a React store hook
86 |
87 | 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.
88 |
89 | ```tsx
90 | import { useStoreWithEqualityFn } from 'zustand/traditional';
91 | import type { TemporalState } from 'zundo';
92 |
93 | function useTemporalStore(): TemporalState;
94 | function useTemporalStore(selector: (state: TemporalState) => T): T;
95 | function useTemporalStore(
96 | selector: (state: TemporalState) => T,
97 | equality: (a: T, b: T) => boolean,
98 | ): T;
99 | function useTemporalStore(
100 | selector?: (state: TemporalState) => T,
101 | equality?: (a: T, b: T) => boolean,
102 | ) {
103 | return useStoreWithEqualityFn(useStoreWithUndo.temporal, selector!, equality);
104 | }
105 |
106 | const App = () => {
107 | const { bears, increasePopulation, removeAllBears } = useStoreWithUndo();
108 | // changes to pastStates and futureStates will now trigger a reactive component rerender
109 | const { undo, redo, clear, pastStates, futureStates } = useTemporalStore(
110 | (state) => state,
111 | );
112 |
113 | return (
114 | <>
115 |
bears: {bears}
116 |
pastStates: {JSON.stringify(pastStates)}
117 |
futureStates: {JSON.stringify(futureStates)}
118 |
119 |
120 |
121 |
122 |
123 | >
124 | );
125 | };
126 | ```
127 |
128 | ## API
129 |
130 | ### The Middleware
131 |
132 | `(config: StateCreator, options?: ZundoOptions) => StateCreator`
133 |
134 | `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.
135 |
136 | ### Bear's eye view
137 |
138 | ```tsx
139 | export interface ZundoOptions {
140 | partialize?: (state: TState) => PartialTState;
141 | limit?: number;
142 | equality?: (pastState: PartialTState, currentState: PartialTState) => boolean;
143 | diff?: (
144 | pastState: Partial,
145 | currentState: Partial,
146 | ) => Partial | null;
147 | onSave?: (pastState: TState, currentState: TState) => void;
148 | handleSet?: (
149 | handleSet: StoreApi['setState'],
150 | ) => StoreApi['setState'];
151 | pastStates?: Partial[];
152 | futureStates?: Partial[];
153 | wrapTemporal?: (
154 | storeInitializer: StateCreator<
155 | _TemporalState,
156 | [StoreMutatorIdentifier, unknown][],
157 | []
158 | >,
159 | ) => StateCreator<
160 | _TemporalState,
161 | [StoreMutatorIdentifier, unknown][],
162 | [StoreMutatorIdentifier, unknown][]
163 | >;
164 | }
165 | ```
166 |
167 | ### Exclude fields from being tracked in history
168 |
169 | `partialize?: (state: TState) => PartialTState`
170 |
171 | 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.
172 |
173 | ```tsx
174 | // Only field1 and field2 will be tracked
175 | const useStoreWithUndoA = create()(
176 | temporal(
177 | (set) => ({
178 | // your store fields
179 | }),
180 | {
181 | partialize: (state) => {
182 | const { field1, field2, ...rest } = state;
183 | return { field1, field2 };
184 | },
185 | },
186 | ),
187 | );
188 |
189 | // Everything besides field1 and field2 will be tracked
190 | const useStoreWithUndoB = create()(
191 | temporal(
192 | (set) => ({
193 | // your store fields
194 | }),
195 | {
196 | partialize: (state) => {
197 | const { field1, field2, ...rest } = state;
198 | return rest;
199 | },
200 | },
201 | ),
202 | );
203 | ```
204 |
205 | #### `useTemporalStore` with `partialize`
206 |
207 | If converting temporal store to a React Store Hook with typescript, be sure to define the type of your partialized state
208 |
209 | ```tsx
210 | interface StoreState {
211 | bears: number;
212 | untrackedStateField: number;
213 | }
214 |
215 | type PartializedStoreState = Pick;
216 |
217 | const useStoreWithUndo = create()(
218 | temporal(
219 | (set) => ({
220 | bears: 0,
221 | untrackedStateField: 0,
222 | }),
223 | {
224 | partialize: (state) => {
225 | const { bears } = state;
226 | return { bears };
227 | },
228 | },
229 | ),
230 | );
231 |
232 | const useTemporalStore = (
233 | // Use partalized StoreState type as the generic here
234 | selector: (state: TemporalState) => T,
235 | ) => useStore(useStoreWithUndo.temporal, selector);
236 | ```
237 |
238 | ### Limit number of historical states stored
239 |
240 | `limit?: number`
241 |
242 | 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.
243 |
244 | ```tsx
245 | const useStoreWithUndo = create()(
246 | temporal(
247 | (set) => ({
248 | // your store fields
249 | }),
250 | { limit: 100 },
251 | ),
252 | );
253 | ```
254 |
255 | ### Prevent unchanged states from getting stored in history
256 |
257 | `equality?: (pastState: PartialTState, currentState: PartialTState) => boolean`
258 |
259 | 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.
260 |
261 | If all of your `zustand` state setters modify state in a way that you want tracked in history, this default is sufficient.
262 |
263 | However, for more precise control over when a state snapshot is stored in `zundo` history, you can provide an `equality` function.
264 |
265 | 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).
266 |
267 | #### Example with deep equality
268 |
269 | ```tsx
270 | import isDeepEqual from 'fast-deep-equal';
271 |
272 | // Use a deep equality function to only store history when currentState has changed
273 | const useStoreWithUndo = create()(
274 | temporal(
275 | (set) => ({
276 | // your store fields
277 | }),
278 | // a state snapshot will only be stored in history when currentState is not deep-equal to pastState
279 | // Note: this can also be more concisely written as {equality: isDeepEqual}
280 | {
281 | equality: (pastState, currentState) =>
282 | isDeepEqual(pastState, currentState),
283 | },
284 | ),
285 | );
286 | ```
287 |
288 | #### Example with shallow equality
289 |
290 | 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.
291 |
292 | ```tsx
293 | import shallow from 'zustand/shallow';
294 |
295 | const useStoreWithUndo = create()(
296 | temporal(
297 | (set) => ({
298 | // your store fields
299 | }),
300 | // a state snapshot will only be stored in history when currentState is not deep-equal to pastState
301 | // Note: this can also be more concisely written as {equality: shallow}
302 | {
303 | equality: (pastState, currentState) => shallow(pastState, currentState),
304 | },
305 | ),
306 | );
307 | ```
308 |
309 | #### Example with custom equality
310 |
311 | You can also just as easily use custom equality functions for your specific application
312 |
313 | ```tsx
314 | const useStoreWithUndo = create()(
315 | temporal(
316 | (set) => ({
317 | // your store fields
318 | }),
319 | {
320 | // Only track history when field1 AND field2 diverge from their pastState
321 | // Why would you do this? I don't know! But you can do it!
322 | equality: (pastState, currentState) =>
323 | pastState.field1 !== currentState.field1 &&
324 | pastState.field2 !== currentState.field2,
325 | },
326 | ),
327 | );
328 | ```
329 |
330 | ### Store state delta rather than full object
331 |
332 | `diff?: (pastState: Partial, currentState: Partial) => Partial | null`
333 |
334 | 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.
335 |
336 | 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.
337 |
338 | 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).
339 |
340 | ```tsx
341 | const useStoreWithUndo = create()(
342 | temporal(
343 | (set) => ({
344 | // your store fields
345 | }),
346 | {
347 | diff: (pastState, currentState) => {
348 | const myDiff = diff(currentState, pastState);
349 | const newStateFromDiff = myDiff.reduce(
350 | (acc, difference) => {
351 | type Key = keyof typeof currentState;
352 | if (difference.type === 'CHANGE') {
353 | const pathAsString = difference.path.join('.') as Key;
354 | acc[pathAsString] = difference.value;
355 | }
356 | return acc;
357 | },
358 | {} as Partial,
359 | );
360 | return isEmpty(newStateFromDiff) ? null : newStateFromDiff;
361 | },
362 | },
363 | ),
364 | );
365 | ```
366 |
367 | ### Callback when temporal store is updated
368 |
369 | `onSave?: (pastState: TState, currentState: TState) => void`
370 |
371 | 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).
372 |
373 | ```tsx
374 | import { shallow } from 'zustand/shallow';
375 |
376 | const useStoreWithUndo = create()(
377 | temporal(
378 | (set) => ({
379 | // your store fields
380 | }),
381 | { onSave: (state) => console.log('saved', state) },
382 | ),
383 | );
384 | ```
385 |
386 | ### Cool-off period
387 |
388 | ```typescript
389 | handleSet?: (handleSet: StoreApi['setState']) => (
390 | pastState: Parameters['setState']>[0],
391 | // `replace` will likely be deprecated and removed in the future
392 | replace: Parameters['setState']>[1],
393 | currentState: PartialTState,
394 | deltaState?: Partial | null,
395 | ) => void
396 | ```
397 |
398 | 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.
399 |
400 | ```tsx
401 | const useStoreWithUndo = create()(
402 | temporal(
403 | (set) => ({
404 | // your store fields
405 | }),
406 | {
407 | handleSet: (handleSet) =>
408 | throttle((state) => {
409 | console.info('handleSet called');
410 | handleSet(state);
411 | }, 1000),
412 | },
413 | ),
414 | );
415 | ```
416 |
417 | ### Initialize temporal store with past and future states
418 |
419 | `pastStates?: Partial[]`
420 |
421 | `futureStates?: Partial[]`
422 |
423 | 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.
424 |
425 | > 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.
426 |
427 | ```tsx
428 | const useStoreWithUndo = create()(
429 | temporal(
430 | (set) => ({
431 | // your store fields
432 | }),
433 | {
434 | pastStates: [{ field1: 'value1' }, { field1: 'value2' }],
435 | futureStates: [{ field1: 'value3' }, { field1: 'value4' }],
436 | },
437 | ),
438 | );
439 | ```
440 |
441 | ### Wrap temporal store
442 |
443 | `wrapTemporal?: (storeInitializer: StateCreator<_TemporalState, [StoreMutatorIdentifier, unknown][], []>) => StateCreator<_TemporalState, [StoreMutatorIdentifier, unknown][], [StoreMutatorIdentifier, unknown][]>`
444 |
445 | 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.
446 |
447 | For a full list of middleware, see [zustand middleware](https://www.npmjs.com/package/lodash.debounce) and [third-party zustand libraries](https://github.com/pmndrs/zustand#third-party-libraries).
448 |
449 | > Note: The `temporal` middleware can be added to the `temporal` store. This way, you could track the history of the history. 🤯
450 |
451 | ```tsx
452 | import { persist } from 'zustand/middleware';
453 |
454 | const useStoreWithUndo = create()(
455 | persist( // <-- persist
456 | temporal(
457 | (set) => ({
458 | // your store fields
459 | }),
460 | {
461 | wrapTemporal: (storeInitializer) => {
462 | persist(storeInitializer, { // <-- persist
463 | name: 'temporal-persist'
464 | });
465 | },
466 | }
467 | )
468 | )
469 | );
470 | ```
471 |
472 | > In the example above, note that we use `persist` twice. The outer persist is persisting your user facing store, and the inner persist, as part of the temporal options, will persist the temporal store that's created by the middleware. Simply put: there are two zustand stores, so you must persist both.
473 |
474 |
475 | ### `useStore.temporal`
476 |
477 | 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](https://github.com/pmndrs/zustand/blob/f0ff30f7c431f6bf25b3cb439d065a7e61355df4/src/vanilla.ts#L8) zustand for more details.
478 |
479 | Use `temporal.getState()` to access to temporal store!
480 |
481 | > While `setState`, `subscribe`, and `destroy` exist on `temporal`, you should not need to use them.
482 |
483 | ### `useStore.temporal.getState()`
484 |
485 | `temporal.getState()` returns the `TemporalState` which contains `undo`, `redo`, and other helpful functions and fields.
486 |
487 | ```tsx
488 | interface TemporalState {
489 | pastStates: TState[];
490 | futureStates: TState[];
491 |
492 | undo: (steps?: number) => void;
493 | redo: (steps?: number) => void;
494 | clear: () => void;
495 |
496 | isTracking: boolean;
497 | pause: () => void;
498 | resume: () => void;
499 |
500 | setOnSave: (onSave: onSave) => void;
501 | }
502 | ```
503 |
504 | #### **Going back in time**
505 |
506 | `pastStates: TState[]`
507 |
508 | `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.
509 |
510 | #### **Forward to the future**
511 |
512 | `futureStates: TState[]`
513 |
514 | `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."
515 |
516 | #### **Back it up**
517 |
518 | `undo: (steps?: number) => void`
519 |
520 | `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.
521 |
522 | #### **Take it back now y'all**
523 |
524 | `redo: (steps?: number) => void`
525 |
526 | `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.
527 |
528 | #### **Remove all knowledge of time**
529 |
530 | `clear: () => void`
531 |
532 | `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.
533 |
534 | **Dispatching a new state will clear all of the future states.**
535 |
536 | #### **Stop and start history**
537 |
538 | `isTracking: boolean`
539 |
540 | `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.
541 |
542 | #### **Pause tracking of history**
543 |
544 | `pause: () => void`
545 |
546 | `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`.
547 |
548 | #### **Resume tracking of history**
549 |
550 | `resume: () => void`
551 |
552 | `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`.
553 |
554 | #### **Programmatically add middleware to the setter**
555 |
556 | `setOnSave: (onSave: (pastState: State, currentState: State) => void) => void`
557 |
558 | `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.
559 |
560 | ## Community
561 |
562 | `zundo` is used by several projects and teams including [Alibaba](https://github.com/alibaba/x-render), [Dify.ai](https://github.com/langgenius/dify), [Stability AI](https://github.com/Stability-AI/StableStudio), [Yext](https://github.com/yext/studio), [KaotoIO](https://github.com/KaotoIO/kaoto-ui), and [NutSH.ai](https://github.com/SysCV/nutsh).
563 |
564 | If this library is useful to you, please consider [sponsoring](https://github.com/sponsors/charkour) the project. Thank you!
565 |
566 | PRs are welcome! [pnpm](https://pnpm.io/) is used as a package manager. Run `pnpm install` to install local dependencies. Thank you for contributing!
567 |
568 | ## Examples
569 |
570 | - [Basic](https://codesandbox.io/s/currying-flower-2dom9?file=/src/App.tsx)
571 | - [with lodash.debounce](https://codesandbox.io/s/zundo-handleset-debounce-nq7ml7?file=/src/App.tsx)
572 | - [with just-debounce-it](https://codesandbox.io/p/sandbox/zundo-forked-9yp7df)
573 | - [SubscribeWithSelector](https://codesandbox.io/s/zundo-with-subscribe-with-selector-forked-mug69t)
574 | - [canUndo, canRedo, undoDepth, redoDepth](https://codesandbox.io/s/zundo-canundo-and-undodepth-l6jclx?file=/src/App.tsx:572-731)
575 | - [with deep equal](https://codesandbox.io/p/sandbox/zundo-deep-equal-qg69lj)
576 | - [with input](https://stackblitz.com/edit/vitejs-vite-jqngm9?file=src%2FApp.tsx)
577 | - [with slices pattern](https://codesandbox.io/p/sandbox/pttx6c)
578 |
579 | ## Migrate from v1 to v2
580 |
581 |
582 | Click to expand
583 |
584 | ## v2.0.0 - Smaller and more flexible
585 |
586 | 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](#api) section for more details. Below is a summary of the changes as well as steps to migrate from v1 to v2.
587 |
588 | ### Breaking Changes
589 |
590 | #### Middleware Option Changes
591 |
592 | - `include` and `exclude` options are now handled by the `partialize` option.
593 | - `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.
594 | - `historyDepthLimit` option has been renamed to `limit`.
595 | - `coolOffDurationMs` option is now handled by the `handleSet` option by wrapping the setter function with a throttle or debounce function.
596 |
597 | #### Import changes
598 |
599 | - The middleware is called `temporal` rather than `undoMiddleware`.
600 |
601 | ### New Features
602 |
603 | #### New Options
604 |
605 | - `partialize` option to omit or include specific fields. By default, the entire state object is tracked.
606 | - `limit` option to limit the number of previous and future states stored in history.
607 | - `equality` option to use a custom equality function to determine when a state change should be tracked. By default, all state changes are tracked.
608 | - `diff` option to store state delta rather than full object.
609 | - `onSave` option to call a function when the temporal store is updated.
610 | - `handleSet` option to throttle or debounce state changes.
611 | - `pastStates` and `futureStates` options to initialize the temporal store with past and future states.
612 | - `wrapTemporal` option to wrap the temporal store with middleware. The `temporal` store is a vanilla zustand store.
613 |
614 | #### New `temporal.getState()` API
615 |
616 | - `undo`, `redo`, and `clear` functions are now always defined. They can no longer be `undefined`.
617 | - `undo()` and `redo()` functions now accept an optional `steps` parameter to go back or forward multiple states at once.
618 | - `isTracking` flag, and `pause`, and `resume` functions are now available on the temporal store.
619 | - `setOnSave` function is now available on the temporal store to change the `onSave` behavior after the store has been created.
620 |
621 | ### Migration Steps
622 |
623 | 1. Update zustand to v4.3.0 or higher
624 | 2. Update zundo to v2.0.0 or higher
625 | 3. Update your store to use the new API
626 | 4. Update imports
627 |
628 | ```diff
629 | - import { undoMiddleware } from 'zundo';
630 | + import { temporal } from 'zundo';
631 | ```
632 |
633 | - If you're using `include` or `exclude`, use the new `partialize` option
634 |
635 | ```tsx
636 | // v1.6.0
637 | // Only field1 and field2 will be tracked
638 | const useStoreA = create()(
639 | undoMiddleware(
640 | set => ({ ... }),
641 | { include: ['field1', 'field2'] }
642 | )
643 | );
644 |
645 | // Everything besides field1 and field2 will be tracked
646 | const useStoreB = create()(
647 | undoMiddleware(
648 | set => ({ ... }),
649 | { exclude: ['field1', 'field2'] }
650 | )
651 | );
652 |
653 | // v2.0.0
654 | // Only field1 and field2 will be tracked
655 | const useStoreA = create()(
656 | temporal(
657 | (set) => ({
658 | // your store fields
659 | }),
660 | {
661 | partialize: (state) => {
662 | const { field1, field2, ...rest } = state;
663 | return { field1, field2 };
664 | },
665 | },
666 | ),
667 | );
668 |
669 | // Everything besides field1 and field2 will be tracked
670 | const useStoreB = create()(
671 | temporal(
672 | (set) => ({
673 | // your store fields
674 | }),
675 | {
676 | partialize: (state) => {
677 | const { field1, field2, ...rest } = state;
678 | return rest;
679 | },
680 | },
681 | ),
682 | );
683 | ```
684 |
685 | - If you're using `allowUnchanged`, use the new `equality` option
686 |
687 | ```tsx
688 | // v1.6.0
689 | // Use an existing `allowUnchanged` option
690 | const useStore = create()(
691 | undoMiddleware(
692 | set => ({ ... }),
693 | { allowUnchanged: true }
694 | )
695 | );
696 |
697 | // v2.0.0
698 | // Use an existing equality function
699 | import { shallow } from 'zustand/shallow'; // or use `lodash.isequal` or any other equality function
700 |
701 | // Use an existing equality function
702 | const useStoreA = create()(
703 | temporal(
704 | (set) => ({
705 | // your store fields
706 | }),
707 | { equality: shallow },
708 | ),
709 | );
710 | ```
711 |
712 | - If you're using `historyDepthLimit`, use the new `limit` option
713 |
714 | ```tsx
715 | // v1.6.0
716 | // Use an existing `historyDepthLimit` option
717 | const useStore = create()(
718 | undoMiddleware(
719 | set => ({ ... }),
720 | { historyDepthLimit: 100 }
721 | )
722 | );
723 |
724 | // v2.0.0
725 | // Use `limit` option
726 | const useStore = create()(
727 | temporal(
728 | (set) => ({
729 | // your store fields
730 | }),
731 | { limit: 100 },
732 | ),
733 | );
734 | ```
735 |
736 | - If you're using `coolOffDurationMs`, use the new `handleSet` option
737 |
738 | ```tsx
739 | // v1.6.0
740 | // Use an existing `coolOffDurationMs` option
741 | const useStore = create()(
742 | undoMiddleware(
743 | set => ({ ... }),
744 | { coolOfDurationMs: 1000 }
745 | )
746 | );
747 |
748 | // v2.0.0
749 | // Use `handleSet` option
750 | const withTemporal = temporal(
751 | (set) => ({
752 | // your store fields
753 | }),
754 | {
755 | handleSet: (handleSet) =>
756 | throttle((state) => {
757 | console.info('handleSet called');
758 | handleSet(state);
759 | }, 1000),
760 | },
761 | );
762 | ```
763 |
764 |
765 |
766 | ## Road Map
767 |
768 | - [ ] create nicer API, or a helper hook in react land (useTemporal). or vanilla version of the it
769 | - [ ] support history branches rather than clearing the future states
770 | - [ ] track state for multiple stores at once
771 |
772 | ## Author
773 |
774 | Charles Kornoelje ([@\_charkour](https://twitter.com/_charkour))
775 |
776 | ## Versioning
777 |
778 | View the [releases](https://github.com/charkour/zundo/releases) for the change log. This project follows semantic versioning.
779 |
780 | ## Illustration Credits
781 |
782 | Ivo Ilić ([@theivoson](https://twitter.com/theivoson))
783 |
--------------------------------------------------------------------------------
/examples/web/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "root": false
3 | }
4 |
--------------------------------------------------------------------------------
/examples/web/README.md:
--------------------------------------------------------------------------------
1 | ## Getting Started
2 |
3 | First, run the development server:
4 |
5 | ```bash
6 | yarn dev
7 | ```
8 |
9 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
10 |
11 | You can start editing the page by modifying `pages/index.js`. The page auto-updates as you edit the file.
12 |
13 | [API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.js`.
14 |
15 | The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages.
16 |
17 | ## Learn More
18 |
19 | To learn more about Next.js, take a look at the following resources:
20 |
21 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
22 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
23 |
24 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
25 |
26 | ## Deploy on Vercel
27 |
28 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_source=github.com&utm_medium=referral&utm_campaign=turborepo-readme) from the creators of Next.js.
29 |
30 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
31 |
--------------------------------------------------------------------------------
/examples/web/next-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 |
4 | // NOTE: This file should not be edited
5 | // see https://nextjs.org/docs/pages/api-reference/config/typescript for more information.
6 |
--------------------------------------------------------------------------------
/examples/web/next.config.mjs:
--------------------------------------------------------------------------------
1 | // @ts-check
2 | /**
3 | * @type {import('next').NextConfig}
4 | */
5 | const nextConfig = {
6 | reactStrictMode: true,
7 | };
8 | export default nextConfig;
9 |
--------------------------------------------------------------------------------
/examples/web/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "web",
3 | "version": "0.0.0",
4 | "private": true,
5 | "scripts": {
6 | "build": "next build",
7 | "dev": "next dev",
8 | "lint": "next lint",
9 | "start": "next start"
10 | },
11 | "dependencies": {
12 | "just-throttle": "4.2.0",
13 | "lodash.merge": "4.6.2",
14 | "lodash.throttle": "4.1.1",
15 | "microdiff": "1.5.0",
16 | "next": "15.1.5",
17 | "react": "19.0.0",
18 | "react-dom": "19.0.0",
19 | "zundo": "workspace:*",
20 | "zustand": "5.0.3"
21 | },
22 | "devDependencies": {
23 | "@types/lodash.merge": "4.6.9",
24 | "@types/lodash.throttle": "4.1.9",
25 | "@types/node": "22.10.7",
26 | "@types/react": "19.0.7",
27 | "eslint": "9.18.0",
28 | "typescript": "5.7.3"
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/examples/web/pages/chained.tsx:
--------------------------------------------------------------------------------
1 | import { temporal } from 'zundo';
2 | import { create } from 'zustand';
3 | import { devtools, persist } from 'zustand/middleware';
4 | import { immer } from 'zustand/middleware/immer';
5 |
6 | interface MyState {
7 | count: number;
8 | increment: () => void;
9 | decrement: () => void;
10 | }
11 |
12 | // const withZundo = zundo(
13 | // (set) => ({
14 | // count: 0,
15 | // increment: () => set((state) => ({ count: state.count + 1 })),
16 | // decrement: () => set((state) => ({ count: state.count - 1 })),
17 | // }),
18 | // {
19 | // handleSet: (handleSet) =>
20 | // throttle((state) => {
21 | // console.error('handleSet called');
22 | // handleSet(state);
23 | // }, 1000),
24 | // },
25 | // );
26 |
27 | // TODO: doing this one by one does not work with the types.
28 | // const withPersist = persist(withZundo);
29 |
30 | // const originalStore = createVanilla(withZundo);
31 |
32 | // const useStore = create(originalStore);
33 |
34 | const useChainedStore = create()(
35 | devtools(
36 | temporal(
37 | immer(
38 | persist(
39 | (set) => ({
40 | count: 0,
41 | increment: () => set((state) => ({ count: state.count + 1 })),
42 | decrement: () => set((state) => ({ count: state.count - 1 })),
43 | }),
44 | {
45 | name: 'test',
46 | },
47 | ),
48 | ),
49 | ),
50 | ),
51 | );
52 |
53 | export default function Web() {
54 | const { count, increment, decrement } = useChainedStore();
55 | const { undo, futureStates, pastStates } =
56 | useChainedStore.temporal.getState();
57 |
58 | return (
59 |
untrackedValue is not tracked in history (partialize)
140 |
equality function is fast-deep-equal
141 |
142 | Note that clicking the button that increments untrackedValue prior to
143 | incrementing bears results in state history of bears not being tracked
144 |
untrackedValue is not tracked in history (partialize)
72 |
equality function is fast-deep-equal
73 |
74 | Note that clicking the button that increments untrackedValue prior to
75 | incrementing bears results in state history of bears not being tracked
76 |