Nov 25 '22Create an Observable Object using Proxy

Create an Observable Object using Proxy

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,

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);

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";
name is being set to fred

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";

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;
  },
};

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

If we add the above method and run we now see

name is being set to fred

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);
}

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));

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";
children.0.name is being set to fred

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);
}

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 = []) {
  ...
}

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

return buildProxy(value, callback, tree.concat(prop));

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);

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";
{
  action: 'set',
  path: 'children.0.name',
  target: { name: 'Oliver' },
  newValue: 'fred',
  previousValue: 'Oliver'
}

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;

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;

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] ]
  }
}

Comments

  • bgrand_ch

    Thank you!

    • dperrymorrow

      For sure thanks for reading