Create an Observable Object using Proxy

David Morrow - Nov 25 '22 - - Dev Community

Have you ever needed to "observe" an object for changes? If you have ever used Vuejs or React, this is what is happening under the hood. Data gets changed, something re-renders. It's the core building block of almost every front end framework.

React uses their own setState to understand when data is changing, but Vue and many others use Javascript's native Proxy which is what we will look at here. Proxy lets you interact with the object like you normal, which I highly prefer to calling setters like in React or Ember.

In this little excersize, we are going to build an "observable" library, that will make Javascript objects observable for change.

Ill take it step by step, but if you just wanna jump to the final code, here you go

How does Proxy work?

So proxy works by calling new Proxy() wich takes two arguments,

  • first argument: the plain object which you are going to be "observing" we will refer to this from here on as poj (Plain Javascript Object) to make things clearer.
  • second argument: the handler object aka "the trap" as we will refer to it.

The trap allows you to hook into when set, delete, get and other actions are taken on the object. The Reflect object lets you hook into the normal actions that would occur during the operations on the object. In this case, setting the value. More info here

const trap = {
  set(target, prop, value) {
    console.log(prop, "is being set to", value);
    return Reflect.set(...arguments);
  },
};

const poj = { name: "David" };
new Proxy(poj, trap);
Enter fullscreen mode Exit fullscreen mode

Let's see it in action

So given the code above, lets see how we could observe changes to our object.

const data = new Proxy(poj, trap);
data.name = "fred";
Enter fullscreen mode Exit fullscreen mode
name is being set to fred
Enter fullscreen mode Exit fullscreen mode

Cool that worked! We can see when a change happens to our object.

What about nested Objects?

But what about if our POJ has nested objects?

const poj = {
  name: "David",
  children: [{ name: "Oliver" }],
};

const data = new Proxy(poj, trap);
data.children[0].name = "fred";
Enter fullscreen mode Exit fullscreen mode

Nothing happens. Why? Well what's happening is that our object has child objects, which are not Proxys, they are Plain Old Javascript Objects. In order to listen for changes in nested objects, we would need to wrap those in new Proxy() as well. But how can we do that?

Add a get hook into our trap

The Proxy handler object, our trap provides a get function that can be used. This will trigger each time a value is retrieved, and we can hook into this and control what gets returned.

Instead of just returning the value, if we are working with an Object, we will wrap it in Proxy and then return it just like we did on the top level.

const trap = {
  ...

  get(target, prop) {
    const value = Reflect.get(...arguments);

    if (
      value &&
      typeof value === "object" &&
      ["Array", "Object"].includes(value.constructor.name)
    )
      return new Proxy(value, trap);

    return value;
  },
};
Enter fullscreen mode Exit fullscreen mode

If we are going to return an Object or Array, we reutrn a wrapped Proxy instead.
We are checking that

  • have a value instead of null or undefined
  • is typeof object
  • and the constructor.name is either "Array" or "Object"

If we add the above method and run we now see

name is being set to fred
Enter fullscreen mode Exit fullscreen mode

Capturing more useful output

Great, we are observing changes on nested objects, but it's hard to tell whats happening, we have a name property on the root and in each of the children. What would really be helpful is to know the path that was changed. Like children.0.name. Let's fix that.

function buildProxy(poj, tree = []) {
  const getPath = (prop) => tree.concat(prop).join(".");

  const trap = {
    set(target, prop, value) {
      console.log(getPath(prop), "is being set to", value);
      return Reflect.set(...arguments);
    },

    get(target, prop) {
      const value = Reflect.get(...arguments);

      if (
        value &&
        typeof value === "object" &&
        ["Array", "Object"].includes(value.constructor.name)
      )
        return buildProxy(value, tree.concat(prop));

      return value;
    },
  };

  return new Proxy(poj, trap);
}
Enter fullscreen mode Exit fullscreen mode

So we have now wrapped the creation of our Proxies in a method called buildProxy wich will allow us to keep passing down the tree that we have traversed. Then when we have a change we can know the path to the item that has changed.

Each time we find a nested Object, we push on the current property to the tree and call the buildProxy method again. The concat method is similar to push, only it creates a new Array instead of effecting the original.

return buildProxy(value, tree.concat(prop));
Enter fullscreen mode Exit fullscreen mode

Ok, lets try it now and see what happens.

const poj = {
  name: "David",
  children: [{ name: "Oliver" }],
};

const data = buildProxy(poj);
data.children[0].name = "fred";
Enter fullscreen mode Exit fullscreen mode
children.0.name is being set to fred
Enter fullscreen mode Exit fullscreen mode

Callback instead of logging

Great thats what we wanted. We have our path to what changed, and what it's being changed to. But the console.log is not really that useful. Like the example I gave up top, say we were trying to re-render based on changes. What we really need is a hook for the changes. Lets fix that.

function buildProxy(poj, callback, tree = []) {
  const getPath = (prop) => tree.concat(prop).join(".");

  const trap = {
    set(target, prop, value) {
      callback({
        action: "set",
        path: getPath(prop),
        target,
        newValue: value,
        previousValue: Reflect.get(...arguments),
      });
      return Reflect.set(...arguments);
    },

    get(target, prop) {
      const value = Reflect.get(...arguments);

      if (
        value &&
        typeof value === "object" &&
        ["Array", "Object"].includes(value.constructor.name)
      )
        return buildProxy(value, callback, tree.concat(prop));

      return value;
    },
  };

  return new Proxy(poj, trap);
}
Enter fullscreen mode Exit fullscreen mode

So main things changed here are we are now passing a callback in addition to the tree. This will give us a method to call when something changes, rather than just logging it out which is not that useful.

function buildProxy(poj, callback, tree = []) {
  ...
}
Enter fullscreen mode Exit fullscreen mode

And then we also need to pass that when we find nested Objects

return buildProxy(value, callback, tree.concat(prop));
Enter fullscreen mode Exit fullscreen mode

Lastly, we are adding a couple more things to the return object we are sending to our callback.

callback({
  action: "set",
  path: getPath(prop),
  target,
  newValue: value,
  previousValue: Reflect.get(...arguments),
});
return Reflect.set(...arguments);
Enter fullscreen mode Exit fullscreen mode
  • We added an action to the object, so the consumer of our callback will know what type of action took place on our Object.
  • we added previousValue so that you can compare the old value to the new value being changed.

We are doing this by using Reflect.get to capture the current value before we set the new value.

putting it all together, this is how you would use the little Observer library we just wrote.

const poj = {
  name: "David",
  children: [{ name: "Oliver" }],
};

const data = buildProxy(poj, (change) => {
  console.log(change);
});

data.children[0].name = "fred";
Enter fullscreen mode Exit fullscreen mode
{
  action: 'set',
  path: 'children.0.name',
  target: { name: 'Oliver' },
  newValue: 'fred',
  previousValue: 'Oliver'
}
Enter fullscreen mode Exit fullscreen mode

There are many other actions you can trap in the Proxy handler. You might want to add delete at least. But by just using set and get, we are able to observe most changes that could occur to our object.

Wrapping up

Here is the final "Observer Library" thanks for reading and I hope you find this useful.

function buildProxy(poj, callback, tree = []) {
  const getPath = (prop) => tree.concat(prop).join(".");

  return new Proxy(poj, {
    get(target, prop) {
      const value = Reflect.get(...arguments);

      if (
        value &&
        typeof value === "object" &&
        ["Array", "Object"].includes(value.constructor.name)
      )
        return buildProxy(value, callback, tree.concat(prop));

      return value;
    },

    set(target, prop, value) {
      callback({
        action: "set",
        path: getPath(prop),
        target,
        newValue: value,
        previousValue: Reflect.get(...arguments),
      });
      return Reflect.set(...arguments);
    },

    deleteProperty(target, prop) {
      callback({ action: "delete", path: getPath(prop), target });
      return Reflect.deleteProperty(...arguments);
    },
  });
}

export default buildProxy;
Enter fullscreen mode Exit fullscreen mode

And how you would use this in your code

import Observer from "./observer.js";

const data = Observer(
  {
    name: "David",
    occupation: "freelancer",
    children: [{ name: "oliver" }, { name: "ruby" }],
  },
  console.log
);

data.name = "Mike";
data.children.push({ name: "baby" });
data.children[0].name = "fred";
delete data.occupation;
Enter fullscreen mode Exit fullscreen mode

and you would see the following log output

{
  action: 'set',
  path: 'name',
  target: {
    name: 'David',
    occupation: 'freelancer',
    children: [ [Object], [Object] ]
  },
  newValue: 'Mike',
  previousValue: 'David'
}
{
  action: 'set',
  path: 'children.2',
  target: [ { name: 'oliver' }, { name: 'ruby' } ],
  newValue: { name: 'baby' },
  previousValue: undefined
}
{
  action: 'set',
  path: 'children.length',
  target: [ { name: 'oliver' }, { name: 'ruby' }, { name: 'baby' } ],
  newValue: 3,
  previousValue: 3
}
{
  action: 'set',
  path: 'children.0.name',
  target: { name: 'oliver' },
  newValue: 'fred',
  previousValue: 'oliver'
}
{
  action: 'delete',
  path: 'occupation',
  target: {
    name: 'Mike',
    occupation: 'freelancer',
    children: [ [Object], [Object], [Object] ]
  }
}
Enter fullscreen mode Exit fullscreen mode
. . . . . . . .
Terabox Video Player