JavaScript Proxy

or why you may not need any framework

complete description of proxy can be found on mdn

but in general the simplest example will be:

const data = {
  foo: 'bar',
  acme: 42
}

const proxied = new Proxy(data, {
  get: function (obj, prop) {
    console.log({proxy: 'get', obj, prop})
    return obj[prop]
  },
  set: function (obj, prop, value) {
    console.log({proxy: 'set', obj, prop, value})
    obj[prop] = value
    return true
  }
})

the most painful and important thing here is that you should not mutate your objects

aka if you are proxying an array and removing items from it like items = items.filter(...) you are loosing propxy, thats why it will work only once

the same is true for inner objects

for demo i will be using previous note about createElement to reduce amount of UI creation code

we will be doing shoping cart demo which will list render of items and allow to modify them, aka:

const items = [
  {id: 1, name: 'product 1', quantity: 1, price: 9.99},
  {id: 2, name: 'product 2', quantity: 2, price: 19.99},
]

function render() {
  // ...
}

screenshot

the problem here is that whenever we made changes we gonna need to call render function manualy

so why not wrap everything into proxy and whenever something changed call it, aka:

const proxied = new Proxy(items, {
  set: function (obj, prop, value) {
    obj[prop] = value
    render()
    return true
  }
})

but we also need to do the same with actual items, otherwise we wont detect changes to quantity so we ended up with:

const handler = {
  set: function (obj, prop, value) {
    obj[prop] = value
    render()
    return true
  }
}

const proxied = new Proxy(items.map(item => new Proxy(item, handler)), handler)

the only last piece is to not forget that whenever we adding something to list we should also wrap it in proxy (technically we may override push method but somehow it behaves really weird)

so at the very end we have something like:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>demo</title>
</head>
<body>
    <div id="root"></div>

    <script>
        HTMLElement.prototype.withAttribute = function (qualifiedName, value) {
            if (typeof value === 'string') {
                this.setAttribute(qualifiedName, value)
            } else {
                this[qualifiedName] = value
            }
            return this
        }

        HTMLElement.prototype.withAttributes = function (props) {
            for (const [key, value] of Object.entries(props)) {
                this.withAttribute(key, value)
            }
            return this
        }

        HTMLElement.prototype.withChild = function () {
            for(const child of arguments) {
                if (Array.isArray(child)) {
                    for(const item of child) {
                        this.append(item)
                    }
                } else {
                    this.append(child)
                }
            }
            return this
        }

        HTMLElement.prototype.withStyle = function (style, value) {
            this.style[style] = value
            return this
        }

        HTMLElement.prototype.withStyles = function (styles) {
            for (const [key, value] of Object.entries(styles)) {
                this.withStyle(key, value)
            }
            return this
        }

        HTMLElement.prototype.clear = function () {
            while (this.firstChild) {
                this.removeChild(this.lastChild);
            }
            return this
        }
    </script>
    <script>
        const catalog = [
            {id: 1, name: 'product 1', price: 9.99},
            {id: 2, name: 'product 2', price: 9.99},
            {id: 3, name: 'product 3', price: 99.99},
        ]

        // let items = [
        //     {id: 1, name: 'product 1', quantity: 1, price: 9.99},
        //     {id: 2, name: 'product 2', quantity: 2, price: 9.99},
        // ]

        const handler = {
            // get: function (obj, prop) {
            //     console.log({proxy: 'get', obj, prop})
            //     return obj[prop]
            // },
            set: function (obj, prop, value) {
                console.log({proxy: 'set', obj, prop, value})
                obj[prop] = value
                render()
                return true
            }
        }

        const data = [
            {id: 1, name: 'product 1', quantity: 1, price: 9.99},
            {id: 2, name: 'product 2', quantity: 2, price: 9.99},
        ]

        // // hack to automatically wrap new items into proxy
        // // fixme: instead use arguments keyword
        // data.push = function (item) {
        //     console.log('push', item)
        //     return Array.prototype.push.apply(this, new Proxy(item, handler));
        // }

        // proxy all the things
        const items = new Proxy(data.map(item => new Proxy(item, handler)), handler)


        function handleFormSubmit(event) {
            event.preventDefault()
            const data = new FormData(event.target)
            const id = parseInt(data.get('id')) // "2"
            const found = catalog.find(i => i.id === id)
            if (!found) {
                console.warn(`${id} not found in catalog`)
                return
            }
            const alreadyAdded = items.find(i => i.id === id)
            if (alreadyAdded) {
                alreadyAdded.quantity += 1
            } else {
                items.push(new Proxy({
                    ...found,
                    quantity: 1
                }, handler))
            }
        }

        function handleDecrement(item) {
            item.quantity -= 1
            if (item.quantity < 1) {
                items.splice(items.indexOf(item), 1)
            }
        }

        function render() {
            document.getElementById('root')
                .clear()
                .append(
                    document.createElement('H3').withChild('Shopping cart'),
                    items.length ? document.createElement('TABLE').withAttribute('border', '1').withChild(
                        document.createElement('THEAD').withChild(
                            document.createElement('TR').withChild(
                                document.createElement('TH').withChild('name'),
                                document.createElement('TH').withChild('quantity'),
                                document.createElement('TH').withChild('price'),
                                document.createElement('TH').withChild('total'),
                                document.createElement('TH'),
                            ),
                        ),
                        document.createElement('TBODY').withChild(
                            items.map(item => document.createElement('TR').withChild(
                                document.createElement('TD').withChild(item.name),
                                document.createElement('TD').withChild(item.quantity),
                                document.createElement('TD').withChild(item.price),
                                document.createElement('TD').withChild(item.price * item.quantity),
                                document.createElement('TD').withChild(
                                    document.createElement('BUTTON').withChild('inc').withAttribute('onclick', () => item.quantity += 1),
                                    document.createElement('BUTTON').withChild('dec').withAttribute('onclick', () => handleDecrement(item)),
                                    document.createElement('BUTTON').withChild('x').withAttribute('onclick', () => items.splice(items.indexOf(item), 1)),
                                ),
                            )),
                        ),
                    ) : document.createElement('P').withChild('Nothing here'),
                    document.createElement('FORM').withAttribute('onsubmit', handleFormSubmit).withChild(
                        document.createElement('P').withChild(
                            document.createElement('SELECT').withAttribute('name', 'id').withChild(
                                catalog.map(item => document.createElement('OPTION').withAttribute('value', item.id).withChild(item.name))
                            ),
                            document.createElement('INPUT').withAttribute('type', 'submit').withAttribute('value', 'add'),
                        ),
                    ),
                )
        }

        render()
    </script>
</body>
</html>