Patches
Record the changes of a recipe as patches, and apply them to a state with apply().
With the enablePatches option, create() also returns the patches that turn the base state into the next state, and the inverse patches that turn the next state back into the base state. apply() applies them, for example to replay changes on another copy of the state, to send them over a network, or to undo and redo changes.
Generating and applying patches
import { apply, create } from 'mutative';
const baseState = {
foo: 'bar',
list: [{ text: 'todo' }],
};
const [state, patches, inversePatches] = create(
baseState,
(draft) => {
draft.foo = 'foobar';
draft.list.push({ text: 'learning' });
},
{
enablePatches: true,
}
);
expect(patches).toEqual([
{ op: 'add', path: ['list', 1], value: { text: 'learning' } },
{ op: 'replace', path: ['foo'], value: 'foobar' },
]);
expect(inversePatches).toEqual([
{ op: 'replace', path: ['list', 'length'], value: 1 },
{ op: 'replace', path: ['foo'], value: 'bar' },
]);
expect(apply(baseState, patches)).toEqual(state);
expect(apply(state, inversePatches)).toEqual(baseState);enablePatches also works with curried producers, manual drafts and makeCreator(), and it supports objects, arrays, Maps, Sets, and the values that a mark makes draftable; pass the same mark to apply().
Patch behavior
- Operations and paths. Patches use
add,removeandreplace. A path leads from the root to the changed value through object keys, array indices, Map keys as they are, objects and arrays included, and the positions of Set items. A recipe that returns a new state gets onereplacepatch with an empty path. - Order. The patches of a nested draft come before those of its parents, as in Immer.
- Values. A patch value is the object that the next state holds, frozen with it when
enableAutoFreezeis on, so copy a patch value before changing it.apply()copies patch values before applying them. - Arrays.
shift(),unshift(),splice(),reverse()andsort()emit areplacepatch for each index whose element changed, and anaddpatch for each index past the old length. An array that gets shorter gets onereplacepatch of itslengthby default, or aremovepatch for each index it loses witharrayLengthAssignment: false. An array that a recipe shortens and lengthens again also gets patches for the indices that the longer length exposes again. - Maps.
set()emits anaddpatch for a new key and areplacepatch for an existing one, anddelete()aremovepatch. - Sets. Patches add an item by its value and remove one by the value it holds; see Sets of objects.
- Moved drafts. A changed draft that leaves its key, because
sort()or an assignment moved it or another value replaced it there, emits no patches under its old path; the patches of its parent carry its value. - Applying patches.
apply()appliesadd,removeandreplacepatches and accepts-as the index that appends to an array. It throws for other operations and for paths through__proto__orconstructor. It takes theenableAutoFreeze,strictandmarkoptions, ormutable: trueto change the state in place, which throws for a patch that replaces the root state with another value, and it changes a draft in place. - JSON Patch. With
pathAsArray: falseandarrayLengthAssignment: false, the patches of objects and arrays are JSON Patch operations. Maps and Sets have no JSON Patch counterpart.
Comparison with Immer lists where these patches differ from Immer's.
JSON patches
enablePatches takes true, or an object with two options that set the shape of the patches. With both set to false, the patches of objects and arrays are JSON Patch operations, which other JSON Patch libraries can apply (related discussion).
pathAsArray - default: true
With pathAsArray: false, a path is a JSON Pointer string instead of an array of keys:
import { create } from 'mutative';
const [state, patches, inversePatches] = create(
{ list: [1, 2, 3] },
(draft) => {
draft.list.length = 0;
},
{
enablePatches: {
pathAsArray: false,
},
}
);
expect(state).toEqual({ list: [] });
expect(patches).toEqual([{ op: 'replace', path: '/list/length', value: 0 }]);
expect(inversePatches).toEqual([
{ op: 'add', path: '/list/0', value: 1 },
{ op: 'add', path: '/list/1', value: 2 },
{ op: 'add', path: '/list/2', value: 3 },
]);A string path holds only strings, so it cannot name a Map key that is not a string, or a symbol key: with pathAsArray: false, the patch of draft.set(1, 'b') would set the key '1'. Keep the default array paths for such keys; development builds throw an error when a string path would hold one.
arrayLengthAssignment - default: true
With arrayLengthAssignment: false, an array that gets shorter gets a remove patch for each index that it loses, instead of a replace patch of its length:
import { create } from 'mutative';
const [state, patches, inversePatches] = create(
{ list: [1, 2, 3] },
(draft) => {
draft.list.length = 0;
},
{
enablePatches: {
arrayLengthAssignment: false,
},
}
);
expect(state).toEqual({ list: [] });
expect(patches).toEqual([
{ op: 'remove', path: ['list', 2] },
{ op: 'remove', path: ['list', 1] },
{ op: 'remove', path: ['list', 0] },
]);
expect(inversePatches).toEqual([
{ op: 'add', path: ['list', 0], value: 1 },
{ op: 'add', path: ['list', 1], value: 2 },
{ op: 'add', path: ['list', 2], value: 3 },
]);A remove patch per index takes more time and more patches for large arrays than one replace patch of the length, so leave arrayLengthAssignment on unless the patches must be JSON Patch operations.
Undo and redo
The inverse patches of each change undo it, and its patches redo it:
import { apply, create, type Patches } from 'mutative';
let state = { todos: [] as string[] };
const undoStack: { patches: Patches; inversePatches: Patches }[] = [];
function update(recipe: (draft: typeof state) => void) {
const [nextState, patches, inversePatches] = create(state, recipe, {
enablePatches: true,
});
state = nextState;
undoStack.push({ patches, inversePatches });
}
update((draft) => {
draft.todos.push('learn Mutative');
});
update((draft) => {
draft.todos.push('use patches');
});
const last = undoStack.pop()!;
state = apply(state, last.inversePatches);
expect(state.todos).toEqual(['learn Mutative']);
state = apply(state, last.patches);
expect(state.todos).toEqual(['learn Mutative', 'use patches']);travels builds undo and redo with history limits, archives and persistence on these patches, and use-travel and zustand-travel bring it to React and Zustand.
Sets of objects
Patches add a Set element by its value and remove one by the value it holds, so a remove patch removes an object only from a Set that holds that same object. A Set whose elements changed in place, without any element added or removed, gets patches under the position of each changed element, and they replay in both directions. Once a recipe adds or removes elements of a Set, the Set's remove and add patches carry every changed element by value instead. apply() copies the value of each patch before applying it, so a Set that apply() changes holds copies of the objects that patches add, and the inverse patches cannot remove those copies: an undo after a redo leaves the object in the Set. apply() also appends every element that it adds, so a Set that it changes can hold its elements in another order than the state that the recipe produced: a changed element of a Set that also added or removed elements, or an element that inverse patches add back, moves to the end, and a recipe that deletes an element and adds it back moves it to the end without a patch. The positions in the patches of a later recipe then find other elements, and its changes under them apply to another element without an error. Sets of primitives keep their elements, but their order can change in the same way. When patches are replayed on states that apply() returned, as undo, redo and synchronized copies do, keep such objects in a Map keyed by an id or in an array instead.
Last updated on by unadlib