React hook for undo/redo functionality without the hassle
useUndoable
React Hook for undo/redo functionality without the hassle. This hook acts like the useState
hook but provides easy-to-use helper functions for undoing/redoing state changes.
Installation
yarn add use-undoable
or
npm install use-undoable
Usage
import useUndoable from 'use-undoable';
const MyComponent = () => {
const initialState = 0;
const [
count,
setCount,
{
undo,
redo
}
] = useUndoable(initialState);
return <p>{count}</p>
}
Features
(and how useuUndoable
improves on existing packages)
- Familiar API similar to
useState
- You can choose how you’d like state changes to be reflected, with
mergePastReversed
,mergePast
,destroyFuture
, orkeepFuture
- Ability to set default options so that each call to
setState
doesn’t need to pass the same mutation behavior. - Set a history limit to prevent huge memory consumption.
- Zero dependencies
- Tiny; less than 40 kB unpacked
Docs
This section explains the values given by the useUndoable
hook. Let’s assume that we’ve called the hook and set all of the exported values:
const [
count,
setCount,
{
past,
future,
undo,
canUndo,
redo,
canRedo,
reset
}
] = useUndoable(initialState, options);
options
Here’s an object showing all the option values in TypeScript format:
const options = {
behavior: 'mergePastReversed' | 'mergePast' | 'destroyFuture' | 'keep future',
historyLimit: number | 'infinium' | 'infinity'
}
Note: options
is not required. It will default to the following if you don’t specify it:
{
behavior: 'mergePastReversed',
historyLimit: 100
}
behavior
Declaring the mutation behavior in the options
sets that behavior for all state changes. This behavior can be overridden within individual state mutations.
const options = {
behavior: 'destroyFuture'
}
With destroyFuture
, all calls to setCount
will destroy the future array on new state changes.
Therefore:
setCount(0);
will use the destroyFuture
behavior and
setCount(0, 'keepFuture');
will keep the future array.
historyLimit
Defines the max size for the history. The default is 100.
The resulting past
array will be +1 whatever number you give. That is, if you set the historyLimit
to 5, there will actually be 6 total items within the array because the present
is merged into it on every state change.
Therefore, if your project absolutely requires there to be only 1,000 total items, for example, set the limit to 999
.
Note: It is highly recommended that you set a limit specific to your project. If your state contains an array of objects, for instance, the history could quickly get out of hand. If you make a state change too often, your project could start consuming a lot of memory. That is why the default is 100
.
count
(state
)
This is the present
state. Think of it like the left side of the useState
hook: const [count, setCount] = useState(0)
.
setCount
(setState
)
This is the updater function. It’s used to modify the current state. Think of it like the right side of the useState
hook: const [count, setCount] = useState(0)
It mimics the useState
behavior in that you can either pass a direct value
setCount(42);
or you can pass a callback function to which the present
state is passed as a parameter:
setCount(c => c + 1);
// Expanded
setCount((count) => {
return count + 1;
})
In this way, you can use the useUndoable
hook just like the useState
hook, just with some extra functions.
When you call this function to update the state, it pushes the current present
value to the past and updates it with value you provide.
Mutation behavior
Version 2.0.0 added the mutationBehavior
argument to this function. It allows you to specify how you’d like the new state to be mutated when you call setCount
.
For example:
setCount(c => c + 1, 'mergePast')
The default value is mergePastReversed
.
The following are the possible values:
mergePastReversed
This will merge the future
(reversed order) into the past
, keeping all the changes in the state.
Let’s say the state looks like this:
{
past: [0, 1, 2, 3],
present: 4,
future: []
}
If you call undo
twice, the state will look like:
{
past: [0, 1],
present: 2,
future: [3, 4]
}
If you then call setCount(c => c + 1)
, the state will finally look like:
{
past: [0, 1, 4, 3, 2],
present: 3,
future: []
}
As you can see here, the future
(3, 4
) was reversed and merged into the past
(0, 1
), right before the previous present
value (2
).
Why do it this way?
Although the standard behavior of most undo/redo packages we’ve found default to the destroyFuture
option, this has a few downsides. Primarily, if you make a state change after undoing something, all of the future states will be lost. With our mergePast...
options, no state change will ever be removed; everything can be undone.
mergePast
This is the same behavior as above, where the future
is pushed into the past
, but the order is not reversed.
As such, the final state would instead look like:
{
past: [0, 1, 3, 4, 2],
present: 3,
future: []
}
destroyFuture
This is the standard behavior of many undo/redo packages you’ll come across. When you undo a change and then update it via setCount
, the future
is reset to an empty array.
Consider the following:
{
past: [0, 1, 2, 3],
present: 4,
future: []
}
If you call undo
twice, the state will look like:
{
past: [0, 1],
present: 2,
future: [3, 4]
}
If you then call setCount(c => c + 1)
, the state will finally look like:
{
past: [0, 1, 2],
present: 3,
future: []
}
keepFuture
This is similar to the above, but instead of deleting the future, it’ll keep it.
Assume we’ve started with the same default state, pressed undo twice like above, the final state will be:
{
past: [0, 1, 2],
present: 3,
future: [3, 4]
}
past
, future
As seen above, both of these objects are just an array of the previous/future actions that you’ve set. You usually won’t need to use either of these directly, but they’re exported anyway.
undo
This is a function accepting zero parameters that pulls from the past
array and moves the most recent item to the present
. While doing so, the present
state is pushed to the future
object.
redo
This is a function that accepts zero parameters that pulls from the future
array and moves the most recent item to the present
. The current present
is pushed to the past
.
canUndo
, canRedo
A boolean that indicates whether or not you can undo the latest action.
reset
This function empties both the past
and future
arrays and sets the present
state with the value you provide. If you do not provide a value, it defaults to the initialState
you originally called the useUndoable
hook with.
reset(42);
Performance considerations
Every time you make a state change, the previous state is saved in memory. It’s duplicated, essentially. 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.
In general, you want to ask yourself the following:
- How can I reduce the size of my state object while still keeping it useable?
- How many actions in the past is reasonable for my project to store?
- Should each “state” be a description of how to mutate some static object, or should it be the state object in itself?
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.
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.
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!).
Changing function/variable names
Since the third value returned from the useUndoable
hook is an object, you can change the names of the values like so:
const [
count,
setCount,
{
past: currentPast,
future: currentFuture,
undo: undoWithCustomName,
canUndo: canUndoWithCustomName,
redo: redoWithCustomName,
canRedo: canRedoWithCustomName,
reset: deleteEverythingYo
}
] = useUndoable([{
count: 1
}]);
deleteEverythingYo({
state: 'My new state'
});
And, of course, you can set count, setCount
to anything you want by default, since they are array items.
Contributing
- Clone repo and install dependencies for the main project with:
yarn install
- Navigate into the example and do the same.
- In the root directory, run
yarn start
- In the example directory, run
yarn start
License
This project is licensed under the terms of the MIT license. See the LICENSE
file.