15

I wonder how to subscribe to the changes of a JavaScript object e.g. like Redux does. I read through a lot of JS documentations but I couldn't find a non-deprecated way to handle this problem (Object.protype.watch() as well as Object.observe() are deprecated). Moreover I read a few Stackoverflow questions regarding this topic but they are all at least 5 years old. To visualize my problem I'll show an example.

This could be an object I want to watch for:

const store = {
    anArray = [
    'Hi',
    'my',
    'name',
    'is'
  ]
}

.. and this a function which changes the store object:

function addAName() {
    store.anArray.push('Bob')
}

My goal in this example is to trigger the following function every time the store object changes

function storeChanged() {
    console.log('The store object has changed!')
}

Thank you in advance!

Eyk Rehbein
  • 3,683
  • 3
  • 18
  • 39
  • 1
    Your question is very broad, and is probably off topic because of it. Please find ways to do this by googling yourself or by browsing different fora. If you end up trying a solution and are stuck there, everyone here will be able to help you here with your specific coding issue. – Glubus Apr 25 '18 at 11:31
  • Possible duplicate of [Object.watch() for all browsers?](https://stackoverflow.com/questions/1029241/object-watch-for-all-browsers) – Heretic Monkey Jun 17 '19 at 15:28

8 Answers8

22

Have you tried using Proxy from ECMA6? I think this is what you are looking for You only have to define a function as the set of the validator of the Proxy like this:

let validator = {
    set: function(target, key, value) {
        console.log(`The property ${key} has been updated with ${value}`);
        return true;
    }
};
let store = new Proxy({}, validator);
store.a = 'hello';
// console => The property a has been updated with hello
Alejandro Riera
  • 411
  • 3
  • 6
  • 1
    This answer helped me a ton! I created a flux-like store based on this principle, very cool! The only thing I am considering now is, to use a norma l class and use set and get properties there. With this approach, you need to specify all variables and become kind of redundant, but on the other hand, you have the power over what kinda values this object controls. With the proxy approach, you have no idea about all the stuff that is being stored in there by simply looking at the file. – codepleb Mar 22 '20 at 08:24
5

To solve this problem without any indirections (in using object) you can use proxy.

By wrapping all objects with observable you can edit your store freely and _base keeps track of which property has changed.

const observable = (target, callback, _base = []) => {
  for (const key in target) {
    if (typeof target[key] === 'object')
      target[key] = observable(target[key], callback, [..._base, key])
  }
  return new Proxy(target, {
    set(target, key, value) {
      if (typeof value === 'object') value = observable(value, callback, [..._base, key])
      callback([..._base, key], target[key] = value)
      return value
    }
  })
}

const a = observable({
  a: [1, 2, 3],
  b: { c: { d: 1 } }
}, (key, val) => {
  console.log(key, val);
})

a.a.push(1)

a.b.c.d = 1
a.b = {}
a.b.c = 1
robstarbuck
  • 6,893
  • 2
  • 41
  • 40
Maciej Kozieja
  • 1,812
  • 1
  • 13
  • 32
  • This is probably a good way of doing this job but one reminder. If you are observing the changes of the `length` propery of an array it wouldn't trap the direct indexed additions such as `a.a[10] = true`. – Redu Apr 25 '18 at 12:49
3

You can use Object.defineproperty() to create reactive getters/setters. It has good browser support and looks handy.

function Store() {

  let array = [];

  Object.defineProperty(this, 'array', {
    get: function() {
      console.log('Get:', array);
      return array;
    },
    set: function(value) {
      array = value;
      console.log('Set:', array)
    }
  });


}

var store = new Store();
store.array; //Get: []
store.array = [11]; //Set: [11]
store.array.push(5) //Set: [11, 5]
store.array = store.array.concat(1, 2, 3) //Set: [11, 5, 1, 2, 3]
soeik
  • 905
  • 6
  • 15
2

It's non-trivial.

There are several approaches that different tools (Redux, Angular, KnockoutJS, etc.) use.

  1. Channeling changes through functions - This is the approach Redux uses (more). You don't directly modify things, you pass them through reducers, which means Redux is aware that you've changed something.

  2. Diffing - Literally comparing the object tree to a previous copy of the object tree and acting on changes made. At least some versions of Angular/AngularJS use(d) this approach.

  3. Wrapping - (Kind of a variant on #1) Wrapping all modification operations on all objects in the tree (such as the push method on your array) with wrappers that notify a controller that they object they're on has been called — by wrapping those methods (and replacing simple data properties with accessor properties) and/or using Proxy objects. KnockoutJS uses a version of this approach.

T.J. Crowder
  • 1,031,962
  • 187
  • 1,923
  • 1,875
  • 2
    [Glubus may have a point](https://stackoverflow.com/questions/50020982/how-to-subscribe-to-object-changes/50021224#comment87058027_50020982), maybe this is too broad... – T.J. Crowder Apr 25 '18 at 11:34
0

I am not sure if there is a way to do it with native functionality and even if there is I think you won't be able to do this without some sort of abstraction.

It doesn't even work natively in react/redux either as you need to specifically call setState explicitly to trigger changes. I recommend a very observer pattern whose implementation roughly looks like this.

var store = {
  names: ['Bob', 'Jon', 'Doe']
}

var storeObservers = [];

Now, simple push your observer functions doesn't matter even if they are part of some component or module

storeObservers.push(MyComponent.handleStoreChange)

Now simple expose a function to change the store similar to setState

function changeStore(store) {
  store = store
  storeObservers.forEach(function(observer){
    observer()
  })
}

You can obviously modularized this to handle more complex situations or if you only want to change a part of state and allow observers to bind callbacks to partial state changes.

Umair Abid
  • 1,453
  • 2
  • 18
  • 35
0

You can try this npm package tahasoft-event-emitter

Your code will look like this

import { EventEmitter } from "tahasoft-event-emitter";

const store = {
  anArray: ["Hi", "my", "name", "is"]
};

const onStoreChange = new EventEmitter();

function addAName(name) {
  onStoreChange.emit(name);
  store.anArray.push(name);
}

function storeChanged(name) {
  console.log("The store object has changed!. New name is " + name);
}

onStoreChange.add((name) => storeChanged(name));

addAName("Bob");

If you are not interested in the new value of name, you can write it simple like this

import { EventEmitter } from "tahasoft-event-emitter";

const store = {
  anArray: ["Hi", "my", "name", "is"]
};

const onStoreChange = new EventEmitter();

function addAName() {
  store.anArray.push("Bob");
  onStoreChange.emit();
}

function storeChanged() {
  console.log("The store object has changed!");
}

onStoreChange.add(storeChanged);

addAName();

Whenever you call addAName, storeChanged will be called

Here is the example for on codesandbox EventEmitter on an object status change

You can check the package GitHub repository to know how it is created. It is very simple.

Zuhair Taha
  • 2,808
  • 2
  • 35
  • 33
0

Here is a solution using Proxy object in Typescript that outputs like this ${key} changed from ${oldValue} to ${newValue}

type PropEventSource<Type> = {
  on<Key extends string & keyof Type>(
    eventName: `${Key}Changed`,
    callback: (newValue: Type[Key], oldValue: Type[Key]) => void
  ): void;
};

function makeWatchedObject<Type>(obj: Type): Type & PropEventSource<Type> {
  const cache = new Map<
    string | symbol,
    (
      newValue: Type[string & keyof Type],
      oldValue: Type[string & keyof Type]
    ) => void
  >();

  const on = <Key extends string & keyof Type>(
    change: string | symbol,
    callback: (newValue: Type[Key], oldValue: Type[Key]) => void
  ) => {
    cache.set(
      typeof change === "string" ? change.replace("Changed", "") : change,
      callback as (
        newValue: Type[string & keyof Type],
        oldValue: Type[string & keyof Type]
      ) => void
    );
  };

  return new Proxy<Type & PropEventSource<Type>>(
    {
      ...obj,
      on: on,
    },
    {
      set: <Key extends string & keyof Type>(
        target: Type,
        p: Key,
        newValue: Type[Key]
      ) => {
        cache.get(p)?.(newValue, target[p]);
        return true;
      },
    }
  );
}

function watchAllProperties<T>(obj: T & PropEventSource<T>) {
  Object.keys(obj).forEach((key) =>
    obj.on(`${key as string & keyof T}Changed`, (newValue, oldValue) => {
      console.log(
        `${key} changed from ${JSON.stringify(oldValue)} to ${JSON.stringify(
          newValue
        )}`
      );
    })
  );
}

type Person = {
  name: string;
  age: number;
  location: { lan: number; log: number };
};

const person = makeWatchedObject<Person>({
  name: "Lydia",
  age: 21,
  location: { lan: 0, log: 0 },
});

type PropEventCallback<Type, Key extends string & keyof Type> = (
  newValue: Type[Key],
  oldValue: Type[Key]
) => void;

const locationChangeHandler: PropEventCallback<Person, "location"> = (
  newValue: Person["location"],
  oldValue: Person["location"]
) => {
  console.log(
    `location changed ${JSON.stringify(oldValue)} to ${JSON.stringify(
      newValue
    )}`
  );
};

person.on("locationChanged", locationChangeHandler);

person.location = { lan: 1, log: 1 }; // location changed {"lan":0,"log":0} to {"lan":1,"log":1}
-2

Alejandro Riera's answer using Proxy is probably the correct approach in most cases. However, if you're willing to include a (small-ish, ~90kb) library, Vue.js can have watched properties on its instances, which can sort of do what you want.

It is probably overkill to use it for just change observation, but if your website has other uses for a reactive framework, it may be a good approach.

I sometimes use it as an object store without an associated element, like this:

const store = new Vue({
    data: {
        anArray: [
            'Hi',
            'my',
            'name',
            'is'
        ]
    },
    watch: {
        // whenever anArray changes, this function will run
        anArray: function () {
            console.log('The store object has changed:', this.anArray);
        }
    }
});

function addAName() {
    // push random strings as names
    const newName = '_' + Math.random().toString(36).substr(2, 6);
    store.anArray.push(newName);
}

// demo
setInterval(addAName, 5000);
tony19
  • 125,647
  • 18
  • 229
  • 307
oelna
  • 2,210
  • 3
  • 22
  • 40