Vue.js - Cleaning up components

Published 12/17/2019

If you write a semi-big vue application you might see familiar patterns popping up repeatedly. Let's take a look at some of these and how we can drastically improve our Vue components.

This is the component we will refactor. Its purpose is to fetch a list of threads. It also handles cases when the thread list is empty, when the component is currently fetching the resource or when there was an error fetching the resource. This currently results in over 50 lines of code.

<template>
<div v-if="error">
  Whoops! Something happened
</div>
<div v-else-if="isPending">
  <LoadingSpinner />
</div>
<div v-else-if="isEmpty" class="empty-results">
  There are no threads!
</div>
<div v-else>
  <ThreadList :threads="threads" />
</div>
</template>
<script>
import LoadingSpinner from '../layouts/LoadingSpinner'
import ThreadList from './ThreadList'

export default {
  components: { LoadingSpinner, ThreadList },
  data() {
    return {
        threads: [],
        error: null,
        isPending: true,
    }
  },
  computed: {
    isEmpty() {
      return !this.isPending && this.threads.length < 1
    }
  },
  async created() {
    try {
      this.threads = await fetch('/api/threads').then(res => res.json())
    } catch (error) {
      this.error = error
    }

    this.isPending = false
  }
}
</script>
<style scoped>
.empty-results {
  margin: 1rem;
  font-size: .875rem;
  text-align: center;
}

@media (min-width: 1024px) {
  margin: .875rem;
}
</style>

There are a ton of improvements we can do without reaching for a state management library like vuex, so let's check them out one by one.

Note that none of these improvements are strictly necessary, but keep the ones you like in your head for the time you do feel like writing components becomes cumbersome.


1. Global components

If you have some general component that you need on a lot of pages it can make sense to register it as a global component. This is exactly the case with our LoadingSpinner.

To register it globally, head over to the file where you instantiate vue, you know, where you also register any modules using Vue.use.

Here, we can now import the loading spinner and register it globally.

import LoadingSpinner from './layouts/LoadingSpinner'

Vue.component('LoadingSpinner', LoadingSpinner)

// ...
// new Vue()

And that's it! Now you can remove the import and component registration from our component, leaving us with:

// ...

<script>
import ThreadList from './ThreadList'

export default {
  components: { ThreadList },
  // ...

2. Error Boundary

Catching errors in every component can become quite cumbersome. Luckily there is a solution for that.

Let's create a new component called ErrorBoundary.vue.

<template>
<div v-if="!!error">
    Whoops! {{ error }}
</div>
<div v-else>
    <slot></slot>
</div>

</template>
<script>
export default {
    data: () => ({
        error: null,
    }),

    errorCaptured (error, vm, info) {
        this.error = error
    },
}
</script>

This is an ErrorBoundary component. We wrap it around components and it will catch errors that were emitted from inside those components and then render the error message instead. (If you use vue-router, wrap it around the router-view, or even higher)

For example:

<template>
<v-app>
  <ErrorBoundary>
    <v-content>
      <v-container fluid>
        <router-view :key="$route.fullPath"></router-view>
      </v-container>
    </v-content>
  </ErrorBoundary>
</v-app>
</template>

<script>
import ErrorBoundary from './layout/ErrorBoundary'

export default {
  components: {
    ErrorBoundary,
  }
}

Nice! Back in our component we can now get rid of the error property and the if condition in the template:

<div v-if="error">
  Whoops! Something happened
</div>

And our created lifecycle method no longer requires the try-catch:

async created() {
    this.threads = await fetch('/api/threads').then(res => res.json())
    this.isPending = false
  }

Check out my e-book!

Learn to simplify day-to-day code and the balance between over- and under-engineering.

3. Utility first CSS

Vue's scoped CSS is truly an amazing feature. But let's see if we can get this even simpler. If you followed some of my previous blog posts you will know I am a big fan of utility first CSS. Let's use tailwind CSS here as an example, but you could potentially also create your own global utility classes to kick things off.

After installing tailwindCSS we can remove all of this

<style scoped>
.empty-results {
  margin: 1rem;
  font-size: .875rem;
  text-align: center;
}

@media (min-width: 1024px) {
  margin: .875rem;
}
</style>

And in our template, the following:

<div v-else-if="isEmpty" class="empty-results">
  There are no threads!
</div>

now becomes:

<div v-else-if="isEmpty" class="m-4 lg:m-3 text-sm text-center">
  There are no threads!
</div>

If you find yourself repeating these classes, put the div in a dumb component! If, on the other hand you find this to be an absolutely horrible way to do CSS, please check out my blog post explaining this approach.

4. promistate

There is still a lot of code that needs to be repeated across similar components, especially this part here:

<script>
export default {
  data() {
    return {
        threads: [],
        isPending: true,
    }
  },
  computed: {
    isEmpty() {
      return !this.isPending && this.threads.length < 1
    }
  }
  // ...
}
</script>

For this, I have written my own little library called promistate to simplify "promised" state like this.

Using promistate the script now becomes:

<script>
import ThreadList from './ThreadList'
import promistate from 'promistate'

export default {
  components: { ThreadList },
  data() {
    const threadsPromise = promistate(() => fetch('/api/threads').then(res => res.json()), { catchErrors: false }) // no fetch fired yet

    return { threadsPromise }
  },
  async created() {
    await this.threadsPromise.load() // callback gets fired and saved inside this object
  }
}
</script>

and the template becomes:

<template>
<div v-if="threadsPromise.isPending">
  <LoadingSpinner v-if="threadsPromise.isPending" />
</div>
<div v-else-if="threadsPromise.isEmpty" class="m-4 lg:m-3 text-sm text-center">
  There are no threads!
</div>
<div v-else>
  <ThreadList :threads="threadsPromise.value" />
</div>
</template>

You can check the promistate documentation for how it works, but basically we simply store the callback you pass in inside data and when you trigger the callback using the load method it sets values like isPending, isEmpty etc. We also pass the option catchErrors: false so the error keeps bubbling up to our ErrorBoundary. You can now decide for yourself if you still need that ErrorBoundary though.

You can even go a step further and create a component that accepts a promise to automatically handle the pending, empty and error states.

5. Remove useless divs

Let's take a look at our template once more. There are quite a few divs inside that we don't actually need. Removing those results in simply

<LoadingSpinner v-if="threadsPromise.isPending" />
<div v-else-if="threadsPromise.isEmpty" class="m-4 lg:m-3 text-sm text-center">
  There are no threads!
</div>
<ThreadList v-else :threads="threadsPromise.value" />
</template>

Alright! Down to 23 lines.

6. Give your code some room to breathe

So far we focused a lot on reducing the LOC (lines of code) in our vue component. But focusing on this one criteria alone could get our code into an equally bad shape as we had before...

I love it when Steve Schoger talks about design, he always says to give your elements more room to breathe. The same can also apply to code!

In fact, I think our component can greatly benefit from adding some space.

Turning

<template>
<LoadingSpinner v-if="threadsPromise.isPending" />
<div v-else-if="threadsPromise.isEmpty" class="m-4 lg:m-3 text-sm text-center">
  There are no threads!
</div>
<ThreadList v-else :threads="threadsPromise.value" />
</template>
<script>
import ThreadList from './ThreadList'
import promistate from 'promistate'

export default {
  components: { ThreadList },
  data() {
    const threadsPromise = promistate(() => fetch('/api/threads').then(res => res.json()), { catchErrors: false })

    return { threadsPromise }
  },
  async created() {
    await this.threadsPromise.load()
  }
}
</script>

into

<template>
<LoadingSpinner v-if="threadsPromise.isPending" />

<div v-else-if="threadsPromise.isEmpty" class="m-4 lg:m-3 text-sm text-center">
  There are no threads!
</div>

<ThreadList v-else :threads="threadsPromise.value" />
</template>

<script>
import ThreadList from './ThreadList'
import promistate from 'promistate'

export default {
  components: { ThreadList },

  data() {
    const threadsPromise = promistate(() => fetch('/api/threads').then(res => res.json()), { catchErrors: false })
    return { threadsPromise }
  },

  async created() {
    await this.threadsPromise.load()
  }
}
</script>

At least for me, this reads a lot easier.


And there you have it, 6 ways to clean up your Vue components. Let's see how the composition API in Vue 3 will change things again!