unadlib / mutative

Efficient immutable updates, 2-6x faster than naive handcrafted reducer, and more than 10x faster than Immer.
http://mutative.js.org/
MIT License
1.64k stars 18 forks source link
immer immutability immutable mutable mutation mutative react reducer redux state-management

Mutative

Mutative Logo

Node CI Coverage Status npm NPM Downloads license

Mutative - A JavaScript library for efficient immutable updates, 2-6x faster than naive handcrafted reducer, and more than 10x faster than Immer.

Why is Mutative faster than the spread operation(naive handcrafted reducer)?

The spread operation has performance pitfalls, which can be detailed in the following article:

And Mutative optimization focus on shallow copy optimization, more complete lazy drafts, finalization process optimization, and more.

Motivation

Writing immutable updates by hand is usually difficult, prone to errors, and cumbersome. Immer helps us write simpler immutable updates with "mutative" logic.

But its performance issue causes a runtime performance overhead. Immer must have auto-freeze enabled by default(Performance will be worse if auto-freeze is disabled), such immutable state with Immer is not common. In scenarios such as cross-processing, remote data transfer, etc., these immutable data must be constantly frozen.

There are more parts that could be improved, such as better type inference, non-intrusive markup, support for more types of immutability, Safer immutability, more edge cases, and so on.

This is why Mutative was created.

Mutative vs Naive Handcrafted Reducer Performance

Mutative vs Reducer benchmark by object: - Naive handcrafted reducer ```ts // baseState type: Record const state = { ...baseState, key0: { ...baseState.key0, value: i, }, }; ``` - Mutative ```ts const state = create(baseState, (draft) => { draft.key0.value = i; }); ``` ![Mutative vs Reducer benchmark by object](benchmark-object.jpg) > Measure(seconds) to update the 1K-100K items object, lower is better([view source](https://github.com/unadlib/mutative/blob/main/test/performance/benchmark-object.ts)).

Mutative is up to 2x faster than naive handcrafted reducer for updating immutable objects.

Mutative vs Reducer benchmark by array: - Naive handcrafted reducer ```ts // baseState type: { value: number }[] // slower 6x than Mutative const state = [ { ...baseState[0], value: i }, ...baseState.slice(1, baseState.length), ]; // slower 2.5x than Mutative // const state = baseState.map((item, index) => // index === 0 ? { ...item, value: i } : item // ); // same performance as Mutative // const state = [...baseState]; // state[0] = { ...baseState[0], value: i }; ``` > The actual difference depends on which spread operation syntax you use. - Mutative ```ts const state = create(baseState, (draft) => { draft[0].value = i; }); ``` ![Mutative vs Reducer benchmark by array](benchmark-array.jpg) > Measure(seconds) to update the 1K-100K items array, lower is better([view source](https://github.com/unadlib/mutative/blob/main/test/performance/benchmark-array.ts)).

Mutative is up to 6x faster than naive handcrafted reducer for updating immutable arrays.

Mutative vs Immer Performance

Mutative passed all of Immer's test cases.

Measure(ops/sec) to update 50K arrays and 1K objects, bigger is better(view source). [Mutative v1.1.0 vs Immer v10.1.1]

Benchmark

Naive handcrafted reducer - No Freeze x 4,670 ops/sec ±0.64% (96 runs sampled)
Mutative - No Freeze x 6,747 ops/sec ±0.61% (95 runs sampled)
Immer - No Freeze x 5.65 ops/sec ±1.53% (19 runs sampled)

Mutative - Freeze x 1,062 ops/sec ±0.74% (95 runs sampled)
Immer - Freeze x 394 ops/sec ±0.85% (93 runs sampled)

Mutative - Patches and No Freeze x 1,011 ops/sec ±0.24% (98 runs sampled)
Immer - Patches and No Freeze x 5.64 ops/sec ±0.22% (19 runs sampled)

Mutative - Patches and Freeze x 545 ops/sec ±1.19% (94 runs sampled)
Immer - Patches and Freeze x 215 ops/sec ±0.70% (86 runs sampled)

The fastest method is Mutative - No Freeze

Run yarn benchmark to measure performance.

OS: macOS 14.7, CPU: Apple M1 Max, Node.js: v22.11.0

Immer relies on auto-freeze to be enabled, if auto-freeze is disabled, Immer will have a huge performance drop and Mutative will have a huge performance lead, especially with large data structures it will have a performance lead of more than 50x.

So if you are using Immer, you will have to enable auto-freeze for performance. Mutative is disabled auto-freeze by default. With the default configuration of both, we can see the 17x performance gap between Mutative (6,747 ops/sec) and Immer (394 ops/sec).

Overall, Mutative has a huge performance lead over Immer in more performance testing scenarios. Run yarn performance to get all the performance results locally.

More Performance Testing Scenarios, Mutative is up to `2.5X-82.9` faster than Immer: ![Mutative vs Immer - All benchmark results by average multiplier](test/benchmark/results/all.jpg) > [view source](https://github.com/unadlib/mutative/blob/main/test/benchmark).

Features and Benefits

Difference between Mutative and Immer

Mutative Immer
Custom shallow copy
Strict mode
No data freeze by default
Non-invasive marking
Complete freeze data
Non-global config
async draft function
Fully compatible with JSON Patch spec
new Set methods(Mutative v1.1.0+)

Mutative has fewer bugs such as accidental draft escapes than Immer, view details.

Installation

Yarn

yarn add mutative

NPM

npm install mutative

CDN

Usage

import { create } from 'mutative';

const baseState = {
  foo: 'bar',
  list: [{ text: 'coding' }],
};

const state = create(baseState, (draft) => {
  draft.list.push({ text: 'learning' });
});

expect(state).not.toBe(baseState);
expect(state.list).not.toBe(baseState.list);

create(baseState, (draft) => void, options?: Options): newState

The first argument of create() is the base state. Mutative drafts it and passes it to the arguments of the draft function, and performs the draft mutation until the draft function finishes, then Mutative will finalize it and produce the new state.

Use create() for more advanced features by setting options.

APIs

create()

Use create() for draft mutation to get a new state, which also supports currying.

import { create } from 'mutative';

const baseState = {
  foo: 'bar',
  list: [{ text: 'todo' }],
};

const state = create(baseState, (draft) => {
  draft.foo = 'foobar';
  draft.list.push({ text: 'learning' });
});

In this basic example, the changes to the draft are 'mutative' within the draft callback, and create() is finally executed with a new immutable state.

create(state, fn, options)

Then options is optional.

create() - Currying

const [draft, finalize] = create(baseState);
draft.foobar.bar = 'baz';
const state = finalize();

Support set options such as const [draft, finalize] = create(baseState, { enableAutoFreeze: true });

const produce = create((draft) => {
  draft.foobar.bar = 'baz';
});
const state = produce(baseState);

Also support set options such as const produce = create((draft) => {}, { enableAutoFreeze: true });

apply()

Use apply() for applying patches to get the new state.

import { create, apply } 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,
  }
);

const nextState = apply(baseState, patches);
expect(nextState).toEqual(state);
const prevState = apply(state, inversePatches);
expect(prevState).toEqual(baseState);

current()

Get the current value from a draft.

It is recommended to minimize the number of times current() is executed when performing read-only operations, ideally executing it only once.

const state = create({ a: { b: { c: 1 } }, d: { f: 1 } }, (draft) => {
  draft.a.b.c = 2;
  expect(current(draft.a)).toEqual({ b: { c: 2 } });
  // The node `a` has been modified.
  expect(current(draft.a) === current(draft.a)).toBeFalsy();
  // The node `d` has not been modified.
  expect(current(draft.d) === current(draft.d)).toBeTruthy();
});

original()

Get the original value from a draft.

const baseState = {
  foo: 'bar',
  list: [{ text: 'todo' }],
};

const state = create(baseState, (draft) => {
  draft.foo = 'foobar';
  draft.list.push({ text: 'learning' });
  expect(original(draft.list)).toEqual([{ text: 'todo' }]);
});

unsafe()

When strict mode is enabled, mutable data can only be accessed using unsafe().

const baseState = {
  list: [],
  date: new Date(),
};

const state = create(
  baseState,
  (draft) => {
    unsafe(() => {
      draft.date.setFullYear(2000);
    });
    // or return the mutable data:
    // const date = unsafe(() => draft.date);
  },
  {
    strict: true,
  }
);

If you'd like to enable strict mode by default in a development build and turn it off for production, you can use strict: process.env.NODE_ENV !== 'production'.

isDraft()

Check if a value is a draft.

const baseState = {
  date: new Date(),
  list: [{ text: 'todo' }],
};

const state = create(baseState, (draft) => {
  expect(isDraft(draft.date)).toBeFalsy();
  expect(isDraft(draft.list)).toBeTruthy();
});

isDraftable()

Check if a value is draftable

const baseState = {
  date: new Date(),
  list: [{ text: 'todo' }],
};

expect(isDraftable(baseState.date)).toBeFalsy();
expect(isDraftable(baseState.list)).toBeTruthy();

You can set a mark to determine if the value is draftable, and the mark function should be the same as passing in create() mark option.

rawReturn()

For return values that do not contain any drafts, you can use rawReturn() to wrap this return value to improve performance. It ensure that the return value is only returned explicitly.

const baseState = { id: 'test' };
const state = create(baseState as { id: string } | undefined, (draft) => {
  return rawReturn(undefined);
});
expect(state).toBe(undefined);

If the return value mixes drafts, you should not use rawReturn().

const baseState = { a: 1, b: { c: 1 } };
const state = create(baseState, (draft) => {
  if (draft.b.c === 1) {
    return {
      ...draft,
      a: 2,
    };
  }
});
expect(state).toEqual({ a: 2, b: { c: 1 } });
expect(isDraft(state.b)).toBeFalsy();

If you use rawReturn(), we recommend that you enable strict mode in development.

const baseState = { a: 1, b: { c: 1 } };
const state = create(
  baseState,
  (draft) => {
    if (draft.b.c === 1) {
      return rawReturn({
        ...draft,
        a: 2,
      });
    }
  },
  {
    strict: true,
  }
);
// it will warn `The return value contains drafts, please don't use 'rawReturn()' to wrap the return value.` in strict mode.
expect(state).toEqual({ a: 2, b: { c: 1 } });
expect(isDraft(state.b)).toBeFalsy();

makeCreator()

makeCreator() only takes options as the first argument, resulting in a custom create() function.

const baseState = {
  foo: {
    bar: 'str',
  },
};

const create = makeCreator({
  enablePatches: true,
});

const [state, patches, inversePatches] = create(baseState, (draft) => {
  draft.foo.bar = 'new str';
});

markSimpleObject()

markSimpleObject() is a mark function that marks all objects as immutable.

const baseState = {
  foo: {
    bar: 'str',
  },
  simpleObject: Object.create(null),
};

const state = create(
  baseState,
  (draft) => {
    draft.foo.bar = 'new str';
    draft.simpleObject.a = 'a';
  },
  {
    mark: markSimpleObject,
  }
);

expect(state.simpleObject).not.toBe(baseState.simpleObject);

View more API docs.

Using TypeScript

Integration with React

FAQs

Yes. Unless you have to be compatible with Internet Explorer, Mutative supports almost all of Immer features, and you can easily migrate from Immer to Mutative.

Migration is also not possible for React Native that does not support Proxy. React Native uses a new JS engine during refactoring - Hermes, and it (if < v0.59 or when using the Hermes engine on React Native < v0.64) does not support Proxy on Android, but React Native v0.64 with the Hermes engine support Proxy.

Yes. Mutative supports return values for reducer, and redux-toolkit is considering support for configurable produce().

Migration from Immer to Mutative

mutative-compat - Mutative wrapper with full Immer API compatibility, you can use it to quickly migrate from Immer to Mutative.

  1. produce() -> create()

Mutative auto freezing option is disabled by default, Immer auto freezing option is enabled by default (if disabled, Immer performance will have a more huge drop).

You need to check if auto freezing has any impact on your project. If it depends on auto freezing, you can enable it yourself in Mutative.

import produce from 'immer';

const nextState = produce(baseState, (draft) => {
  draft[1].done = true;
  draft.push({ title: 'something' });
});

Use Mutative

import { create } from 'mutative';

const nextState = create(baseState, (draft) => {
  draft[1].done = true;
  draft.push({ title: 'something' });
});
  1. Patches
import { produceWithPatches, applyPatches } from 'immer';

enablePatches();

const baseState = {
  age: 33,
};

const [nextState, patches, inversePatches] = produceWithPatches(
  baseState,
  (draft) => {
    draft.age++;
  }
);

const state = applyPatches(nextState, inversePatches);

expect(state).toEqual(baseState);

Use Mutative

import { create, apply } from 'mutative';

const baseState = {
  age: 33,
};

const [nextState, patches, inversePatches] = create(
  baseState,
  (draft) => {
    draft.age++;
  },
  {
    enablePatches: true,
  }
);

const state = apply(nextState, inversePatches);

expect(state).toEqual(baseState);
  1. Return undefined
import produce, { nothing } from 'immer';

const nextState = produce(baseState, (draft) => {
  return nothing;
});

Use Mutative

import { create, rawReturn } from 'mutative';

const nextState = create(baseState, (draft) => {
  return rawReturn(undefined);
});

Contributing

Mutative goal is to provide efficient and immutable updates. The focus is on performance improvements and providing better APIs for better development experiences. We are still working on it and welcome PRs that may help Mutative.

Development Workflow:

License

Mutative is MIT licensed.