├── .gitignore ├── LICENSE ├── README.md ├── demo ├── package.json ├── public │ └── index.html ├── src │ ├── App.tsx │ ├── index.tsx │ └── jupiterui.css ├── tsconfig.json └── yarn.lock ├── package-lock.json ├── package.json ├── react-flow-example ├── .env ├── package.json ├── public │ └── index.html ├── src │ ├── App.jsx │ ├── els.js │ ├── index.jsx │ └── jupiterui.css └── yarn.lock ├── src ├── errors.ts ├── index.ts ├── mutate.ts ├── reducer.ts ├── types.ts └── useUndoable.ts ├── tsconfig.json └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .DS_Store 3 | 4 | dist/ -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Infinium LLC. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # useUndoable 2 | 3 | React Hook adding undo/redo functionality to `useState` with a hassle-free API and customizable behavior. 4 | 5 | [**See the Live Demo**](https://codesandbox.io/s/use-undoable-zi0b4) 6 | 7 | Integrating with React Flow v10? [Read this.](#integration-with-react-flow-v10) 8 | 9 | ## Installation 10 | 11 | ```bash 12 | yarn add use-undoable 13 | ``` 14 | 15 | or 16 | 17 | ```bash 18 | npm install use-undoable 19 | ``` 20 | 21 | ## Basic Usage 22 | 23 | ```js 24 | import useUndoable from "use-undoable" 25 | 26 | const MyComponent = () => { 27 | const initialState = 0 28 | 29 | const [count, setCount, { undo, redo }] = useUndoable(initialState) 30 | 31 | return ( 32 | <> 33 |
{count}
34 | 35 | > 36 | ) 37 | } 38 | ``` 39 | 40 | ## Features 41 | 42 | (and how `useUndoable` improves on existing packages) 43 | 44 | - Familiar API similar to `useState` 45 | - You can choose how you'd like state changes to be reflected, with `mergePastReversed`, `mergePast`, `destroyFuture`, or `keepFuture` 46 | - Ability to set default options so that each call to `setState` doesn't need to pass the same mutation behavior. 47 | - Set a history limit to prevent huge memory consumption. 48 | - Zero dependencies 49 | - Tiny; less than 40 kB unpacked 50 | 51 | ## Why? 52 | 53 | There are existing packages that provide undo/redo functionality for React. So why, then, does this project exist? **To fill the gaps they don't.** 54 | 55 | As explained in this README, most undo/redo packages default to the `destroyFuture` behavior. I don't think this is a good approach. There's a problem: after undoing some change and then making another change on that branch, the previous state changes are destroyed. 56 | 57 | The problem with many existing projects that I've found is that they implicitly force a certain behavior. If you don't like the `future` being destroyed, you either have to implement your own solution or deal with it. 58 | 59 | Or, as evidenced by the fact that you're reading this, you use useUndoable. This project excels in subjectivity. That is, the behavior of the state can be adjusted at your discretion, and on-the-fly. **No longer do we have this lock-in behavior that dictates how users use your application.** 60 | 61 | If you like what you're reading, continue on below to learn how to use it. 62 | 63 | ## Documentation 64 | 65 | useUndoable is one of those projects that doesn't have a massive layer of abstraction. In comparison to a project like, say, React, _how_ you use the program greatly differs from how it is built. 66 | 67 | This project is slightly different. Instead of just going through the API in a superficial way, I will actually teach you, more or less, how useUndoable works under-the-hood. You see, this project is, in a general sense, quite simple. It is imperative, however, that you have a deeper understanding of how it works. Having this understanding will allow you to make better decisions about the options and behaviors this package offers. 68 | 69 | This README is the entire documentation. It is written such that you are meant to read it from top to bottom, without skipping around. 70 | 71 | The best way to visualize how the useUndoable state system works is to use the [live demo](https://codesandbox.io/s/use-undoable-zi0b4) as a companion to this README. Simply open it up and make the state there match the examples offered below. 72 | 73 | Let's start by going into the API of the hook. Afterwards, we'll move into the options and behavior. 74 | 75 | ### The API 76 | 77 | The API is rather straightforward. You start by initializing the state, giving it a name and naming the updater function. Then, you simply initialize the `undo` and `redo` functions in an object. 78 | 79 | ### State 80 | 81 | ```js 82 | const [yourState, setYourState, { undo, redo }] = useUndoable(initialState) 83 | ``` 84 | 85 | Notice how the left-two variables look similar to the `useState` API: 86 | 87 | ```js 88 | const [yourState, setYourState] = useState(initialState) 89 | ``` 90 | 91 | This is an intentional choice. You see, useUndoable is designed to mimick this behavior—both in looks and functionality. 92 | 93 | One primary thing to note is that the updater function (`setYourState`) accepts both a direct value **and** a so-called "functional updater," just like `useState`. The functional updater is given the current state as a parameter. 94 | 95 | That is, both of these are valid: 96 | 97 | ```js 98 | setYourState(yourState + 1) 99 | ``` 100 | 101 | ```js 102 | setYourState(currentState => currentState + 1) 103 | ``` 104 | 105 | The `setState` function accepts, in total, 3 parameters: 106 | 107 | - The state you want to set or a function to set it 108 | - The mutation behavior of this one `setState` call (optional) 109 | - The `ignoreAction` boolean (optional) 110 | 111 | The mutation behavior is described below. Normally, the mutation behavior is a global value, but you can alter it within individual state updates if you want. 112 | 113 | The `ignoreAction` boolean indicates whether or not to update the `past` and `future` of useUndoable's internal state. Essentially, if you set this to `true`, when you make a state update with `setState(c + 1)`, it will **only** update the **present** state. In other words, it will act like the normal `useState` hook. 114 | 115 | If you want to use the global mutation behavior and set the `ignoreAction` bool, set mutation behavior to `null`: 116 | 117 | ```js 118 | const [yourState, setYourState, { undo, redo }] = useUndoable() 119 | 120 | setYourState(yourState + 1, null, true) 121 | ``` 122 | 123 | Heads up: Are you pulling data from an API? Stick around to read how to handle that properly with useUndoable. 124 | 125 | ### Undoing and Redoing changes 126 | 127 | Let's take a moment and look at what the internal state of useUndoable looks like: 128 | 129 | ```js 130 | { 131 | past: [0, 1, 2], 132 | present: 3, 133 | future: [] 134 | } 135 | ``` 136 | 137 | When you make a state update with `setYourState`, the `present` value is passed into the `past` array. 138 | 139 | Let's take the above object and call `undo()` on it. The resulting state would look like: 140 | 141 | ```js 142 | { 143 | past: [0, 1], 144 | present: 2, 145 | future: [3] 146 | } 147 | ``` 148 | 149 | and, by extension, the `redo()` function will do the opposite of this, making the object go back to the initial example. 150 | 151 | Simply call `undo` and `redo` whenever you'd like, and those changes will be reflected in the state and your component will re-render with the new data. 152 | 153 | --- 154 | 155 | That covers most of the API you'll be working with. There are, however, some lesser-used API values that are explained after the Options & Behavior section below. 156 | 157 | ### Options & Behavior 158 | 159 | #### Options 160 | 161 | The `useUndoable` hook accepts two parameters: `initialState` and `options`. The latter is not required, and the default options will be specified later. 162 | 163 | The `options` object looks like this: 164 | 165 | ```ts 166 | interface Options { 167 | behavior?: 168 | | "mergePastReversed" 169 | | "mergePast" 170 | | "destroyFuture" 171 | | "keepFuture" 172 | historyLimit?: number | "infinium" | "infinity" 173 | ignoreIdenticalMutations?: boolean 174 | cloneState?: boolean 175 | } 176 | ``` 177 | 178 | The `historyLimit` is a number that limits the amount of items in the `past` array. This is particularly useful when your state is relatively large. 179 | 180 | The default is `100` items. 181 | 182 | `ignoreIdenticalMutations` and `cloneState` are related. If you don't change `ignoreIdenticalMutations`, you don't need to worry about the other. 183 | 184 | Essentially, there are some specific cases where you actually _do_ need useUndoable to allow identical mutations (where you update the state with a value it already has). These cases are rare, but enough exist to warrant this specific option. 185 | 186 | In short, if you find useUndoable acting weird, try changing this option and see if it helps. 187 | 188 | If you do end up using this option, you have access to the `cloneState` option (default `false`) which just determines whether or not to return the existing state or a cloned version (this can help with triggering re-renders). 189 | 190 | #### Behavior 191 | 192 | You can customize the behavior of undo/redo actions by specifying one of the following: `mergePastReversed`, `mergePast`, `destroyFuture`, or `keepFuture` 193 | 194 | To describe these, let's go through an example. 195 | 196 | Assume we start with the following state object: 197 | 198 | ```js 199 | { 200 | past: [], 201 | present: 0, 202 | future: [] 203 | } 204 | ``` 205 | 206 | Let's call `setYourState(s => s + 1)` twice. This leaves us with: 207 | 208 | ```js 209 | { 210 | past: [0, 1], 211 | present: 2, 212 | future: [] 213 | } 214 | ``` 215 | 216 | Let us now call `undo()` twice. We are left with: 217 | 218 | ```js 219 | { 220 | past: [], 221 | present: 0, 222 | future: [1, 2] 223 | } 224 | ``` 225 | 226 | Great. This is the starting point for the behavior. 227 | 228 | Calling `undo()` essentially creates a new branch of state changes. The `behavior` specifies how to recover from _after_ a state change that followed an `undo()`. 229 | 230 | The `destroyFuture` option, like I explained briefly above, is the most common behavior that I have seen. It essentially just discards the `future` if you make a state change after an undo. 231 | 232 | Let's go back to this state: 233 | 234 | ```js 235 | { 236 | past: [], 237 | present: 0, 238 | future: [1, 2] 239 | } 240 | ``` 241 | 242 | If we call `setYourState(s => s + 1)` now, it would erase the future. The resulting state would look like: 243 | 244 | ```js 245 | { 246 | past: [0], 247 | present: 1, 248 | future: [] 249 | } 250 | ``` 251 | 252 | This, as I explained, is potentially unexpected behavior. The state values `1` and `2` have been erased! The user can't go back. 253 | 254 | This option is provided just in case it fits your use case, but there are three more to discuss. 255 | 256 | The `mergePastReversed` and `mergePast` options are the most common, and ones that users probably expect. 257 | 258 | What they do is simply merge the `future` into the `past`, meaning that every single state change can be navigated back to. The only difference between these two, as indicated by the name, is how the future looks after being merged. 259 | 260 | `mergePastReversed`, understandably, reverses the `future` before merging it into the `past`. 261 | 262 | The other option is the `keepFuture` option, which simply does not touch the `future` array. 263 | 264 | Therefore, contrary to the `destroyFuture` option, the resulting object would look like: 265 | 266 | ```js 267 | { 268 | past: [0], 269 | present: 1, 270 | future: [1, 2] 271 | } 272 | ``` 273 | 274 | ### Other values 275 | 276 | The hook exports a few other values that are useful in certain scenarios. Let's call the hook and set all of the values it provides: 277 | 278 | ```js 279 | const [ 280 | state, 281 | setState, 282 | 283 | { 284 | past, 285 | future, 286 | 287 | undo, 288 | canUndo, 289 | redo, 290 | canRedo, 291 | reset, 292 | static_setState, 293 | }, 294 | ] = useUndoable(initialState, options) 295 | ``` 296 | 297 | `canUndo` and `canRedo` are just booleans indicating whether or not you can technically undo or redo any state changes. 298 | 299 | `reset` is a function allowing you to erase the entire state and start the `present` off with a value. If you don't pass a value, it will default to `initialState`. 300 | 301 | ### `resetInitialState` (handling `async`) 302 | 303 | If you're dynamically updating the state from an async function or accepting data via an HTTP request, your `initialState` may begin as an empty or `undefined` object. `resetInitialState` will allow you to prevent the `undo` function from going back to that `undefined` object. 304 | 305 | Imagine you're pulling an array of todo items from your API. Initially, you set the `initialState` as an empty array (`[]`). If users can drag items around to change the order and then undo their change, they would, previously, be able to undo all the way back to that empty array. 306 | 307 | `resetInitialState` allows you to replace the first item (index `0`) of the `past` array to anything you want, potentially preventing your users from undoing to nothingness. Consider the following code: 308 | 309 | ```js 310 | const MyComponent = () => { 311 | const [ 312 | todos, 313 | setTodos, 314 | { 315 | undo, 316 | redo 317 | } 318 | ] = useUndoable([]); 319 | 320 | useEffect(() => { 321 | // query your API and set the todos 322 | setTodos(api.queryForTodos()); 323 | }, []); 324 | 325 | return ( 326 | // ... 327 | ); 328 | }; 329 | ``` 330 | 331 | With this setup, your users can undo back to that empty array defined here: `useUndoable([])` 332 | 333 | Let's fix this so that the user can only undo back to the array sent from the API: 334 | 335 | ```js 336 | const MyComponent = () => { 337 | const [ 338 | todos, 339 | setTodos, 340 | { 341 | undo, 342 | redo, 343 | resetInitialState 344 | } 345 | ] = useUndoable([]); 346 | 347 | useEffect(() => { 348 | // query your API and set the todos 349 | const apiTodos = api.queryForTodos(); 350 | 351 | setTodos(apiTodos); 352 | resetInitialState(apiTodos); 353 | }, []); 354 | 355 | return ( 356 | // ... 357 | ); 358 | }; 359 | ``` 360 | 361 | Note: it is important that this function is only called once. If you call it multiple times with an existing state, you run the risk of accidentally deleteing _legitimate_ state values and replacing it with some generic (or "starting") one. 362 | 363 | One more note: This function **does not** reset the _actual_ state you passed into the hook itself (`useUndoable([])`); it only changes the item at index `0` in the `past` array. 364 | 365 | ### `static_setState` 366 | 367 | In some rare cases, you may run into the issue that the default `setState` function changes with every state change. This is, more or less, by design. 368 | 369 | One key behavior of the default `setState` is that you can pass either a value or a function. The function receives the present state as a parameter. As such, the function itself needs to change whenever the present state changes. 370 | 371 | If you have an issue with this, you can use the `static_setState` function. This **does not** accept a function, only a new value. This means it only needs to be created once. 372 | 373 | ## Performance considerations 374 | 375 | Every time you make a state change, the previous state is saved in memory (and the previous one, and the previous one, ...). Because of this, **you** need to think carefully about how you want to store your state, how many changes in the past you want to keep, and so on. 376 | 377 | In general, you want to ask yourself the following: 378 | 379 | - How can I reduce the size of my state object while still keeping it usable? 380 | - How many actions in the past is reasonable for my project to store? (See `historyLimit`) 381 | - Should each "state" be a description of how to mutate some static object, or should it be the state object in itself? 382 | 383 | On that last point: consider that your state is a large array of objects with many properties. Instead of storing the entire state, you could store descriptions of how the state was modified. For instance: `item at index 5 -> change 'name' property to value 'infinium'`. This way, your state changes are more efficient and use less memory than storing the entire array of objects. 384 | 385 | In general, you probably won't have to worry too much about performance. If you're using very large data sets, however, we urge you to consider the above. 386 | 387 | If you spot an area where performance can be improved within our source code, please create an issue to let us know (or a pull request!). 388 | 389 | ## Changing function/variable names 390 | 391 | Since the third value returned from the `useUndoable` hook is an object, you can change the names of the values like so: 392 | 393 | ```js 394 | const [ 395 | count, 396 | setCount, 397 | { 398 | past: currentPast, 399 | future: currentFuture, 400 | 401 | undo: undoWithCustomName, 402 | canUndo: canUndoWithCustomName, 403 | redo: redoWithCustomName, 404 | canRedo: canRedoWithCustomName, 405 | reset: deleteItAll, 406 | }, 407 | ] = useUndoable([ 408 | { 409 | count: 1, 410 | }, 411 | ]) 412 | 413 | deleteItAll({ 414 | state: "My new state", 415 | }) 416 | ``` 417 | 418 | And, of course, you can set `count, setCount` to anything you want by default, since they are array items. 419 | 420 | ## Integration with React Flow v10 421 | 422 | React Flow v10 added new (and much better) ways to manage the internal state of the `ReactFlow` component. Most notably, the state of nodes and edges have been separated. When you try to integrate useUndoable with v10 as their documentation suggestions, you'll run into some weird issues with re-renders and state updates. 423 | 424 | The primary cause for most of these issues is that **you are trying to manage multiple state instances at once.** In effect, useUndoable may try to overwrite React Flow's state and vice/versa. 425 | 426 | The solution to this problem is very simple: make useUndoable the **exclusive** state manager for nodes and edges. 427 | 428 | This way, nothing will be arbitrarily overwritten, and it will work as expected. 429 | 430 | See the updated `react-flow-example/src/App.jsx` file for ways to work with React Flow v10. 431 | 432 | The `react-flow-example` does do something "unexpected:" moving a node counts as a **series** of state updates. This means that useUndoable **will count each drag event as individual pieces of history.** 433 | 434 | For instance: 435 | 436 | - You have Node A with position 0, 0 437 | - You move Node A to position 100, 100 438 | 439 | This is not one state update in React Flow v10. Instead, a `drag` event is fired for, more or less, every unit you move: 0 -> 1 -> 2 -> 3 -> and so on. 440 | 441 | This is not a flaw with useUndoable itself, because those state changes are legitimate. Therefore, it is up to you and your project to determine the best way to track React Flow's state changes. 442 | 443 | ## Contributing 444 | 445 | 1. Clone repo and install dependencies for the main project with: `yarn install` 446 | 2. Navigate into the example and do the same. 447 | 3. In the root directory, run `yarn start` 448 | 4. In the example directory, run `yarn start` 449 | 450 | ## License 451 | 452 | This project is licensed under the terms of the MIT license. See the `LICENSE` file. 453 | -------------------------------------------------------------------------------- /demo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "demo", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@types/node": "^16.7.13", 7 | "@types/react": "^18.0.0", 8 | "@types/react-dom": "^18.0.0", 9 | "use-undoable": "link:..", 10 | "react": "link:../node_modules/react", 11 | "react-dom": "link:../node_modules/react-dom", 12 | "react-scripts": "5.0.1", 13 | "typescript": "^4.4.2" 14 | }, 15 | "scripts": { 16 | "start": "react-scripts start" 17 | }, 18 | "eslintConfig": { 19 | "extends": [ 20 | "react-app" 21 | ] 22 | }, 23 | "browserslist": { 24 | "production": [ 25 | ">0.2%", 26 | "not dead", 27 | "not op_mini all" 28 | ], 29 | "development": [ 30 | "last 1 chrome version", 31 | "last 1 firefox version", 32 | "last 1 safari version" 33 | ] 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /demo/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 |Past: {getVisualItem(past)}
55 |Present: {count}
56 |Future: {getVisualItem(future)}
57 | 58 | 59 | 60 |historyLimit: 100
97 | 98 | 101 |