« Back to home

Handling errors in Vue.js should be simple, right? Well, practice shows that there are some gotchas.

This article describes a solution to handle all unexpected errors centrally in a Vue.js application, to process and visualize them in an uniform way. Here's how the end result looks like:

Specifically for this purpose, Vue.js has a very handy errorCaptured hook. This is how it works:

Vue.component('bad-component', {
    template: `<div>The bad guy</div>`,
    created: function() { throw new Error('oops!') }
});

var vm = new Vue({
    template: `<div>
        <h2>My application</h2>
        <bad-component></bad-component>
    </div>`,
    errorCaptured: function(err, component, details) {
        alert(err);
    }
});

show(vm);

By the way: code examples are editable.

The hook can be added not only to the Vue instance, but also to any Vue component. Errors are bubbling up through the components to the Vue instance, triggering all errorCaptured hooks on the way. In order to stop propagation, errorCaptured should return false.

And that's the whole theory. So far so good, isn't it?

But...

But let's test some other scenarios. For example, what if error happens in a method?

Vue.component('bad-component', {
    template: `<button @click="badOne">Click me</button>`,
    methods: {
        badOne: function() { throw new Error('from method') }
    }
});

var vm = new Vue({
    template: `<div>
        <h2>My application</h2>
        <bad-component></bad-component>
    </div>`,
    errorCaptured: function(err, component, details) {
        alert(err);
    }
})

show(vm)

You can click it as much as you want, with no effect.

If you open devtools, you'll see that error actually happens:

...but somehow it doesn't get caught by errorCaptured hook?

Here's the catch: turns out, errorCaptured hook handles only a limited part of a Vue.js application:

  • render functions
  • watcher callbacks
  • lifecycle hooks
  • component event handlers

That's quite disappointing to be honest, I was expecting that this thing is taken care of in Vue... It seems that some improvements to errorCaptured functionality are coming, but for now, I had to implement them myself :)

After some googling and experimentation, here's what I came up with:


// wrap all methods of component with try-catch
var handleMethodErrorsMixin = {
    beforeCreate: function() {
        var methods = this.$options.methods || {}
        for (var key in methods) {
            var original = methods[key];
            methods[key] = function () {
                try {
                    return original.apply(this, arguments);
                } catch (e) {
                    handleError(e, this, key);
                }
            }
        }
    }
}

// handle errors same way as Vue.js handles them
function handleError(error, vm, info) {
    let cur = vm;
    while (cur = cur.$parent) {
        var hooks = cur.$options.errorCaptured || [];
        for (let hook of hooks)
            if (hook.call(cur, error, vm, info) === false) break;
    }
}

// add the mixin
Vue.mixin(handleMethodErrorsMixin);

// same code as above
Vue.component('bad-component', {
    template: `<button @click="badOne">Click me</button>`,
    methods: {
        badOne: function() { throw new Error('from method') }
    }
});

var vm = new Vue({
    template: `<div>
        <h2>My application</h2>
        <bad-component></bad-component>
    </div>`,
    errorCaptured: function(err, component, details) {
        alert(err);
    }
})

show(vm)

Doesn't seem to be too complex, isn't it? Wrap methods into try-catch, and then if error was caught, propagate it up to the main instance same as Vue does.

Going Async

Ok, but what if we make things a bit trickier and mark the method as async?

// those two are unchanged
var handleMethodErrorsMixin = {
    beforeCreate: function() {
        var methods = this.$options.methods || {}
        for (var key in methods) {
            var original = methods[key];
            methods[key] = function () {
                try {
                    return original.apply(this, arguments);
                } catch (e) {
                    handleError(e, this, key);
                }
            }
        }
    }
}
function handleError(error, vm, info) {
    let cur = vm;
    while (cur = cur.$parent) {
        var hooks = cur.$options.errorCaptured || [];
        for (let hook of hooks)
            if (hook.call(cur, error, vm, info) === false) break;
    }
}

Vue.mixin(handleMethodErrorsMixin);

Vue.component('bad-component', {
    template: `<button @click="badOne">Click me</button>`,
    methods: {
        // this is now an async function!
        badOne: async function() {
            var response = await fetch("https://non-existent-url");
            var data = await response.json();
            console.log(data);
        }
    }
});

// also unchanged
var vm = new Vue({
    template: `<div>
        <h2>My application</h2>
        <bad-component></bad-component>
    </div>`,
    errorCaptured: function(err, component, details) {
        alert(err);
    }
});

show(vm)

Nope, that doesn't work.

Again, error is visible the console, but not in our errorCaptured handler.

But since async method returns a Promise, it's actually possible to detect it and wrap to try-catch accordingly. Here's how it's done:

var handleMethodErrorsMixin = {
    beforeCreate: function() {
        var _self = this;
        var methods = this.$options.methods || {}
        for (var key in methods) {
            var original = methods[key];
            methods[key] = function () {
                try {
                    var result = original.apply(this, arguments);
                    // let's analyse what is returned from the method
                    if (result && typeof result.then === "function"
                               && typeof result.catch === "function") {
                        // this looks like a Promise. let's handle it's errors:
                        return result.catch(function(err) {
                            handleError(err, _self, key)
                        });
                    } else
                        return result;
                } catch (e) {
                    handleError(e, _self, key);
                }
            }
        }
    }
}

// everything else is unchanged
function handleError(error, vm, info) {
    let cur = vm;
    while (cur = cur.$parent) {
        var hooks = cur.$options.errorCaptured || [];
        for (let hook of hooks)
            if (hook.call(cur, error, vm, info) === false) break;
    }
}
Vue.mixin(handleMethodErrorsMixin);
Vue.component('bad-component', {
    template: `<button @click="badOne">Click me</button>`,
    methods: {
        badOne: async function() {
            var response = await fetch("https://non-existent-url");
            var data = await response.json();
            console.log(data);
        }
    }
});
var vm = new Vue({
    template: `<div>
        <h2>My application</h2>
        <bad-component></bad-component>
    </div>`,
    errorCaptured: function(err, component, details) {
        alert(err);
    }
});

show(vm)

And yes, this works!

Unfortunately, if a method would look e.g. like this:

badOne: function() {
    fetch("https://non-existent-url")
        .then(response => response.json())
        .then(data => console.log(data));
}

, or if it would use XHR, or setTimeout or anything similar, then the error won't be caught...

However, it's already not bad!

There are always some rules in the team, and there can be another one maybe? I.e. "Either use async methods or return promises from methods that do async stuff". And to enforce it in big teams, it is possible to create a build plugin that would validate the code and throw error if this rule is violated.

Alternatively, it's probably possible to leverage Zone.js or similar library here for tracking asynchronous operations. I decided not to go that far, but you can try ;)

Displaying the error

Let's now polish our error handling off a little bit and implement some UI for displaying the error message and highlighting component where it occured.

Because we're using errorCaptured (as opposed to the global config.errorHandler), we have the context where we can easily display some error message.

So if we add to template something like:

<div v-if="error" class="app-error">{{ error }}</div>

Then we can display it like this:

var vm = new Vue({
    // ....
    data: {
        error: ""
    },
    errorCaptured: function(err, component, details) {
        error = err.toString();
    }
}

Notice we also have component as one of the parameters. And Vue components have $el property that points to the DOM element. Even though it's a bad practice to do something directly with DOM elements when you are using Vue.js, but we're now talking about exceptional situations which shouldn't happen at all (in best case scenario).

So to highlight the component, we can use $el. Here's the naïve code implementing this idea:

errorCaptured: function(err, component, details) {
    this.error = err.toString() || "Unexpected error has occured!";
    component.$el.classList.add("error-frame");
}

By testing this in practice, I was able to discover some cases when $el is not yet available when errorCaptured is fired (e.g. when error happens in render function), so you need to defer it with setTimeout. Also, if render fails, $el will be empty comment element, so we need to detect that as well and probably highlight the parent component instead.

Finally, we also need to have a close button so that user can clear the error message.

Ok, so here's the final version:

var handleMethodErrorsMixin = {
    beforeCreate: function() {
        var _self = this;
        var methods = this.$options.methods || {}
        for (var key in methods) {
            var original = methods[key];
            methods[key] = function () {
                try {
                    var result = original.apply(this, arguments);
                    // let&#39;s analyse what is returned from the method
                    if (result &amp;&amp; typeof result.then === &#34;function&#34;
                               &amp;&amp; typeof result.catch === &#34;function&#34;) {
                        // this looks like a Promise. let&#39;s handle it&#39;s errors:
                        return result.catch(function(err) {
                            handleError(err, _self, key)
                        });
                    } else
                        return result;
                } catch (e) {
                    handleError(e, _self, key);
                }
            }
        }
    }
}
function handleError(error, vm, info) {
    let cur = vm;
    while (cur = cur.$parent) {
        var hooks = cur.$options.errorCaptured || [];
        for (let hook of hooks)
            if (hook.call(cur, error, vm, info) === false) break;
    }
}
Vue.mixin(handleMethodErrorsMixin);
Vue.component(&#39;bad-component&#39;, {
    template: `&lt;button @click=&#34;badOne&#34;&gt;Click me&lt;/button&gt;`,
    methods: {
        badOne: async function() {
            var response = await fetch(&#34;https://non-existent-url&#34;);
            var data = await response.json();
            console.log(data);
        }
    }
});

var vm = new Vue({
    template: `&lt;div&gt;
        &lt;h2&gt;My application&lt;/h2&gt;
        &lt;bad-component&gt;&lt;/bad-component&gt;
        &lt;div v-if=&#34;error&#34; class=&#34;app-error&#34;&gt;
            {{ error }}
            &lt;span class=&#34;close-button&#34; v-on:click=&#34;removeError&#34;&gt;&amp;#10005;&lt;/span&gt;
        &lt;/div&gt;
    &lt;/div&gt;`,
    data: {
        error: &#34;&#34;,
        errorEl: null
    },
    errorCaptured: function(err, component, details) {
        var _self = this;
        _self.error = err.toString() || &#34;Unexpected error has occured!&#34;;
        setTimeout(function() {
            _self.errorEl = component.$el;
            if (!_self.errorEl || _self.errorEl.nodeName==&#34;#comment&#34;)
                _self.errorEl = component.$parent.$el;
            _self.errorEl.classList.add(&#34;error-frame&#34;);
        }, 0)
    },
    methods: {
        removeError: function() {
            if (!this.error)
                return;
            this.error = &#34;&#34;;
            this.errorEl.classList.remove(&#34;error-frame&#34;);
            this.errorEl = null;
        }
    }
});

show(vm)

By the way, if you're using vue-router, it might be also useful to remove error automatically if user leaves current view:

router.afterEach((to, from) => {
    vm.removeError();
});

Conclusion

This is one of rare areas that clearly was not taken care of in Vue.js. Some improvements are planned by the team, hopefully it will become better with time.

On the other hand, clearly there's no easy way to catch async errors, and a complex solution would mean a lot of added code, which is certainly not what we want: cost of JavaScript is high, and size of the framework is very important.

Comments

comments powered by Disqus