Search code examples
javascriptimmutabilitychange-tracking

How to track changes with shared, immutable reference types in Javascript


Consider the following example:

function add(x, y) { return x + y; }

var collection = Object.freeze([1, 2, 3, 4]);
var consumerA = collection; // expects steady data
var consumerB = collection; // requires the latest data
var updatedCollection = collection.concat(5);

consumerA.reduce(add, 0); // 10 (desired result)
consumerB.reduce(add, 0); // 10 (incorrect result, should be 15)

consumerA operates with the immutable data it expects. What can be done in Javascript to ensure that consumerB always accesses the latest data?

Please notice: Just deep copying consumerA and treating collection as mutable data isn't an option.

UPDATE: The example merely serves to illustrate the fundamental problem, which is caused by shared reference types: Some consumers (or reference holder) rely on immutable, others on mutable data. I'm looking for a proper change tracking mechanism that solves this problem without undermining the benefits of immutable data.

Maybe the term "change tracking" is too vague. With change tracking I mean a way for consumerB to be informed about the change (push mechanism) or (more interesting) to be able to discover the change (pull mechanism). The latter would require that consumerB somehow gets access to the updated collection.


Solution

  • Well, that's my only solution, but there are probably others. I wrap my immutable collection in a mutable object. A consumer which needs constant data holds a reference to the collection itself. A consumer which requires current state holds a reference to the wrapper. I use a primitive form of structural sharing in order to avoid cloning:

    function add(x, y) { return x + y; }
    
    var collection = Object.freeze([1, 2, 3, 4]);
    var atom = {state: collection};
    var consumerA = collection;
    var consumerB = atom;
    
    console.log(consumerA === consumerB.state); // true (obviously)
    
    // naive structural sharing to avoid cloning
    atom.state = Object.create(atom.state, {length: {value: atom.state.length, writable: true}});
    atom.state.push(5);
    Object.freeze(atom.state);
    
    // as desired
    console.log(consumerA.reduce(add, 0)); // 10
    console.log(consumerB.state.reduce(add, 0)); // 15
    
    // structural sharing is used
    console.log(Object.getPrototypeOf(consumerB.state) === collection); // true
    
    // object comparison simply by reference check
    console.log(consumerA === consumerB.state); // false
    

    By wrapping an immutable collection in a mutable wrapper it becomes a kind of persistent data type. That means it can be treated as a normal, mutable object but leaves its previous versions untouched, hence persistent. By the way, to name the wrapper atom isn't an accident, but a reference to the corresponding data type in Clojure.

    Please note: To use the prototype system for structural sharing can lead to memory leaking and should be used with caution only.