mutative icon indicating copy to clipboard operation
mutative copied to clipboard

Proposal: Add support for onChange callbacks

Open unadlib opened this issue 1 year ago • 8 comments

Proposal

I propose adding support for a mutation callback feature to trigger state change events. This would allow developers to provide a callback function to track mutations (e.g., state changes, patches, and inverse patches) during a state update.

Use Case

This feature would be especially helpful for:

  • Logging state changes for debugging or analytics purposes.
  • Integrating with tools that depend on patch operations, such as undo/redo systems.
  • Reacting to state changes in a controlled manner without manually diffing the state.

Suggested API

Here’s an example of how this could work:

import { create } from 'mutative';

const state = { count: 0, items: [] };

const draft = create(state, { 
  onChange: (state, patches, inversePatches) => {
    console.log('State updated:', state);
    console.log('Patches:', patches);
    console.log('Inverse Patches:', inversePatches);
  },
  enablePatches: true,
});

draft.count += 1; // The `onChange` callback is executed here with `state`, `patches`, and `inversePatches`.

Callback Function Signature:

onChange?: (state: T, patches?: Patch[], inversePatches?: Patch[]) => void;

Parameters:

  • state: The updated state after the mutation.
  • patches: An array of patches representing the changes made.
  • inversePatches: An array of patches that can revert the changes.

Implementation Notes

  • The onChange function would be executed every time a mutation occurs.
  • It would receive the following parameters:
  • state: The updated state.
  • patches: A list of patches representing the changes made to the state.
  • inversePatches: A list of patches that can revert the changes.

This API would be backward-compatible and opt-in, ensuring it does not affect existing users who do not need the onChange functionality.

Benefits

  • Enhances the usability of Mutative for advanced use cases like undo/redo or logging.
  • Keeps the core principles of Mutative intact while extending its functionality.

unadlib avatar Nov 25 '24 15:11 unadlib

Just migrated to mutative days ago, and this feature is EXACTLY what i need. When editing data in my UI I currently have an internal custom "proxyObserver" wrapping a clone of my data and giving me onChange callbacks (for showing if data is changed). But with this I can replace my proxyObserver and also generate patches and minimize the mutations in the changed data.

atsjo avatar Jan 07 '25 11:01 atsjo

hi @atsjo, I may start implementing this API soon.

unadlib avatar Jan 07 '25 16:01 unadlib

Made it work with the current version by wrapping the draft in my custom proxyObserver, so using double proxy for the state... Will I get into problems if never finalizing a draft, but just dropping the reference in js, like memory leaks or performance?

atsjo avatar Jan 07 '25 18:01 atsjo

These drafts are not finalized, which means they might require shallow copying under the immutable mechanism, and the draft instances will not be garbage collected.

I don’t really recommend doing this. It seems like the proxyObserver you mentioned is probably an observer for mutable types. I’ve previously implemented a transactional mutable state update library https://github.com/mutativejs/mutability, though I’m not sure if it’s what you’re looking for.

unadlib avatar Jan 08 '25 12:01 unadlib

ok, thanks for the info!

I normally use it like a normal producer, but also have a forms based ui bound to a json structure, where this is used to generate the diff from last saved state. When saving I generate the diff via finalize, but I didn't bother finalizing the draft if the user cancels the edit session...

My proxyObserver is just for getting callbacks when users change anything via the ui, so I can show if there are modifications and show the save button... If I could get a callback on every change from mutative I wouldn't need it...

I'll just ensure finalize is called also when users cancels the changes....

atsjo avatar Jan 08 '25 13:01 atsjo

ok, thanks for the info!

I normally use it like a normal producer, but also have a forms based ui bound to a json structure, where this is used to generate the diff from last saved state. When saving I generate the diff via finalize, but I didn't bother finalizing the draft if the user cancels the edit session...

My proxyObserver is just for getting callbacks when users change anything via the ui, so I can show if there are modifications and show the save button... If I could get a callback on every change from mutative I wouldn't need it...

I'll just ensure finalize is called also when users cancels the changes....

hi @atsjo ,

I suggest using mutative in this scenario like this. This ensures drafts are discarded and minimizes the risk of memory leaks.

import { create, original, rawReturn } from 'mutative';

const nextState = create(initState, (draft) => {
  // some logic about `shouldBeChanged`
  if (!shouldBeChanged) {
    return rawReturn(original(draft));
  }
});

unadlib avatar Jan 08 '25 15:01 unadlib

Just wanted to add another use case that I believe would be able to take advantage of this wonderful proposal:

  • interleaving other non-standard operations inside create's mutate callback

For example, I have a JSON-like structure that is almost exactly supported by mutative, but I have a few non-standard things like a "Counter" class that can be contained within the JSON. I'd like to be able to both allow it to be mutated by the user, and capture the changes.

Right now, I'm able to achieve nearly everything I need without onChange via a custom proxy, but I can't detect the order of operations that occur inside the mutate callback. This means it's impossible to correctly interleave the patches from mutative with the patches from my custom proxy once the draft is created or the patches are returned.

With the use of an onChange callback, I would be able to track the sequence in a counter and properly interleave both standard and non-standard operations at the end.

canadaduane avatar Aug 26 '25 14:08 canadaduane

Just wanted to add another use case that I believe would be able to take advantage of this wonderful proposal:

  • interleaving other non-standard operations inside create's mutate callback

For example, I have a JSON-like structure that is almost exactly supported by mutative, but I have a few non-standard things like a "Counter" class that can be contained within the JSON. I'd like to be able to both allow it to be mutated by the user, and capture the changes.

Right now, I'm able to achieve nearly everything I need without onChange via a custom proxy, but I can't detect the order of operations that occur inside the mutate callback. This means it's impossible to correctly interleave the patches from mutative with the patches from my custom proxy once the draft is created or the patches are returned.

With the use of an onChange callback, I would be able to track the sequence in a counter and properly interleave both standard and non-standard operations at the end.

@canadaduane , in your example, I'm not sure if the Counter class is a mutable class. If it is, then you could consider using it like this:

class Counter {
  count = 0;

  increase() {
    this.count++;
  }
}

const data = {
  foo: {
    bar: 'str',
  },
  counter: new Counter(),
};

const state = create(
  data,
  (draft) => {
    draft.foo.bar = 'new str';
    draft.counter.increase();
  },
  {
    mark: (target, { mutable }) => {
      if (target instanceof Counter) return mutable;
    },
  }
);
expect(state).toEqual({
  foo: { bar: 'new str' },
  counter: { count: 1 },
});
expect(state).not.toBe(data);
expect(state.foo).not.toBe(data.foo);
expect(state.counter).toBe(data.counter);
expect(state.counter.count).toBe(1);

BTW, the implementation requires every change to be marked, which could be an issue for fully lazy drafts (as seen in https://github.com/unadlib/mutative/pull/75). This is the exact trade-off I was considering.

unadlib avatar Aug 26 '25 16:08 unadlib