Vue without View - An Introduction to Renderless Components
Tags: vue, webdev
Published: 2019-06-22 16:41:23

{% youtube j_WU0xx_O58 %}


Components get harder to maintain as they grow larger. Sometimes it is not obvious how to split a bloated component into smaller components. The code gets more noisy and they become hard to reason about.

In this post, I am going to introduce the idea of "renderless components" which could potentially help you improve your components.

## My Amazing Website

We are going to look into the [source](https://github.com/ycmjason-talks/2019-06-20-vue-js-london-meetup-11) of the [My Amazing Website](https://ycmjason-talks.github.io/2019-06-20-vue-js-london-meetup-11/). (Do not peak into PR yet if you don't want spoilers.)

## The Groovy Footer

See that groovy footer at the bottom of the page? Let's have a look at the source for that footer.

[src/components/Footer.vue](https://github.com/ycmjason-talks/2019-06-20-vue-js-london-meetup-11/blob/master/src/components/Footer.vue):

```html
<template>
  <footer :style="footerStyle">
    <div class="text" :style="textStyle">Made with ❤ by Jason Yu &copy; 2019</div>
    <label class="insane-mode-label">
      <input type="checkbox" v-model="insaneMode"> Insane Mode (new!)
    </label>
  </footer>
</template>

<script>
import { randomNumber, randomPercentage, randomColor } from '../services/random';

const FOOTER_INTERVAL_MS = 543;
const TEXT_INTERVAL_MS = FOOTER_INTERVAL_MS / 3;

export default {
  mounted() {
    this.randomFooterStyle();
    this.randomTextStyle();
    this.footerIntervalId = window.setInterval(this.randomFooterStyle, this.footerIntervalMs);
    this.textIntervalId = window.setInterval(this.randomTextStyle, this.textIntervalMs);
  },
  beforeDestroy() {
    window.clearInterval(this.footerIntervalId);
    window.clearInterval(this.textIntervalId);
  },
  data: () => ({
    footerStyle: null,
    textStyle: null,
    insaneMode: false,
  }),
  computed: {
    insaneFactor() {
      return this.insaneMode ? 3 : 1;
    },
    footerIntervalMs() {
      return FOOTER_INTERVAL_MS / this.insaneFactor;
    },
    textIntervalMs() {
      return FOOTER_INTERVAL_MS / this.insaneFactor;
    },
  },
  watch: {
    insaneMode() {
      window.clearInterval(this.footerIntervalId);
      window.clearInterval(this.textIntervalId);
      this.footerIntervalId = window.setInterval(this.randomFooterStyle, this.footerIntervalMs);
      this.textIntervalId = window.setInterval(this.randomTextStyle, this.textIntervalMs);
    },
  },
  methods: {
    randomFooterStyle() {
      const { insaneFactor } = this;
      this.footerStyle = {
        borderRadius: `${randomPercentage()} ${randomPercentage()} / ${randomPercentage()} ${randomPercentage()}`,
        background: randomColor(),
        transitionDuration: `${FOOTER_INTERVAL_MS / insaneFactor}ms`,
      };
    },
    randomTextStyle() {
      const { insaneFactor } = this;
      this.textStyle = {
        transform: `rotate(${randomNumber(
          -3 * insaneFactor,
          3 * insaneFactor,
        )}deg) scale(${randomNumber(0.7 * insaneFactor, 1.3 * insaneFactor)})`,
        color: randomColor(),
        transitionDuration: `${TEXT_INTERVAL_MS / insaneFactor}ms`,
      };
    },
  },
};
</script>

<style scoped>
footer {
  margin-top: 1rem;
  padding: 3rem 0;
  transition-property: border-radius, background;
  text-align: center;
}
footer .text {
  transition-property: color, transform;
}
.insane-mode-label {
  display: block;
  margin-top: 2rem;
}
</style>
```

Notice how more than half of the code in `<script>` are used to deal with `window.setInterval` and `window.clearInterval`. How could we simplify this component? It doesn't make sense to move the footer text and the background into their own components, because they semantically belong to footer not on their own!

## \<Interval>

Let's create a component called `<Interval>` which would handle everything related to `window.setInterval` and `window.clearInterval` for us. 

src/components/renderless/Interval.js:
```js
export default {
  render: () => null,
};
```

First of all, as the title of this article suggests, the `render` function should render nothing. So we return `null`. 

### Props

Next, what sort of props should `<Interval>` accepts? Clearly we wish to be able to control the `delay` between each interval.

src/components/renderless/Interval.js:
```js
export default {
  props: {
    delay: {
      type: Number,
      required: true,
    },
  },
  render: () => null,
}
```

### Mounted

When the `<Interval>` is mounted, we expect it to start the interval and would tear the interval off at `beforeDestroyed`.

src/components/renderless/Interval.js:
```js
export default {
  props: {
    delay: {
      type: Number,
      required: true,
    },
  },
  mounted () {
    this.id = window.setInterval(() => /* ... */, this.delay);
  },
  beforeDestroy () {
    window.clearInterval(this.id);
  },
  render: () => null,
}
```

### What should we do in `/* ... */`?

`setInterval` takes in two arguments, a callback and a delay. So should we take in the `callback` as a prop? That's a great idea and could work nicely. But I would say a more "Vue-ish" way is to emit events!

src/components/renderless/Interval.js:
```js
export default {
  props: {
    delay: {
      type: Number,
      required: true,
    },
  },
  mounted () {
    this.id = window.setInterval(() => this.$emit('tick'), this.delay);
  },
  beforeDestroy () {
    window.clearInterval(this.id);
  },
  render: () => null,
}
```

### DONE!

As simple as it is, it empowers us with the power of interval without needing to manage interval ids and the setup/tear-down of the interval!

## Refactor Footer.vue!

Let's handle the `setInterval` and `clearInterval` in the `mounted` and `beforeDestroy` hooks respectively in Footer.vue:
```js
// ...
  mounted() {
    // ...
    this.footerIntervalId = window.setInterval(this.randomFooterStyle, this.footerIntervalMs);
    this.textIntervalId = window.setInterval(this.randomTextStyle, this.textIntervalMs);
  },
  beforeDestroy() {
    window.clearInterval(this.footerIntervalId);
    window.clearInterval(this.textIntervalId);
  },
// ...
```
 
The code above can now be replaced by:

```html
   <Interval :delay="footerIntervalMs" @tick="randomFooterStyle"></Interval>
   <Interval :delay="textIntervalMs" @tick="randomTextStyle"></Interval>
```

The resulting Footer.vue will look like:
```html
<template>
  <footer :style="footerStyle">
    <Interval :delay="footerIntervalMs" @tick="randomFooterStyle"></Interval>
    <Interval :delay="textIntervalMs" @tick="randomTextStyle"></Interval>
    <div class="text" :style="textStyle">Made with ❤ by Jason Yu &copy; 2019</div>
    <label class="insane-mode-label">
      <input type="checkbox" v-model="insaneMode"> Insane Mode (new!)
    </label>
  </footer>
</template>

<script>
import { randomNumber, randomPercentage, randomColor } from '../services/random';
import Interval from './renderless/Interval';

const FOOTER_INTERVAL_MS = 543;
const TEXT_INTERVAL_MS = FOOTER_INTERVAL_MS / 3;

export default {
  mounted() {
    this.randomFooterStyle();
    this.randomTextStyle();
  },
  data: () => ({
    footerStyle: null,
    textStyle: null,
    insaneMode: false,
  }),
  computed: {
    insaneFactor() {
      return this.insaneMode ? 3 : 1;
    },
    footerIntervalMs() {
      return FOOTER_INTERVAL_MS / this.insaneFactor;
    },
    textIntervalMs() {
      return FOOTER_INTERVAL_MS / this.insaneFactor;
    },
  },
  watch: {
    insaneMode() {
      window.clearInterval(this.footerIntervalId);
      window.clearInterval(this.textIntervalId);
      this.footerIntervalId = window.setInterval(this.randomFooterStyle, this.footerIntervalMs);
      this.textIntervalId = window.setInterval(this.randomTextStyle, this.textIntervalMs);
    },
  },
  methods: {
    randomFooterStyle() {
      const { insaneFactor } = this;
      this.footerStyle = {
        borderRadius: `${randomPercentage()} ${randomPercentage()} / ${randomPercentage()} ${randomPercentage()}`,
        background: randomColor(),
        transitionDuration: `${FOOTER_INTERVAL_MS / insaneFactor}ms`,
      };
    },
    randomTextStyle() {
      const { insaneFactor } = this;
      this.textStyle = {
        transform: `rotate(${randomNumber(
          -3 * insaneFactor,
          3 * insaneFactor,
        )}deg) scale(${randomNumber(0.7 * insaneFactor, 1.3 * insaneFactor)})`,
        color: randomColor(),
        transitionDuration: `${TEXT_INTERVAL_MS / insaneFactor}ms`,
      };
    },
  },
};
</script>

<style scoped>
footer {
  margin-top: 1rem;
  padding: 3rem 0;
  transition-property: border-radius, background;
  text-align: center;
}
footer .text {
  transition-property: color, transform;
}
.insane-mode-label {
  display: block;
  margin-top: 2rem;
}
</style>
```

Notice how much nicer already the component looks? No more ridiculous names like `footerIntervalId` or `textIntervalId` and no need to worry any more about forgetting to tear intervals off!

## Insane Mode

The insane mode is powered by the watcher in Footer.vue:
```html
<template>
   <!-- ... -->
   <Interval :delay="footerIntervalMs" @tick="randomFooterStyle"></Interval>
   <Interval :delay="textIntervalMs" @tick="randomTextStyle"></Interval>
   <!-- ... -->
</template>

<script>
// ...
  watch: {
    insaneMode() {
      window.clearInterval(this.footerIntervalId);
      window.clearInterval(this.textIntervalId);
      this.footerIntervalId = window.setInterval(this.randomFooterStyle, this.footerIntervalMs);
      this.textIntervalId = window.setInterval(this.randomTextStyle, this.textIntervalMs);
    },
  },
// ...
</script>
```

We would obviously like to remove this watcher and move the logic inside `<Interval>`.

When the insane mode is triggered, the `<Interval>` receives a new `delay` prop since `this.footerIntervalMs` and `this.textIntervalMs` are changed. However, `<Interval>` has not yet been programmed to react to the change of `delay`. We can add a watcher to `delay` which will tear down the existing interval and set up a new one.

Interval.js
```js
export default {
  props: {
    delay: {
      type: Number,
      required: true,
    },
  },
  mounted () {
    this.id = window.setInterval(() => this.$emit('tick'), this.delay);
  },
  beforeDestroy () {
    window.clearInterval(this.id);
  },
  watch: {
    delay () {
      window.clearInterval(this.id);
      this.id = window.setInterval(() => this.$emit('tick'), this.delay);
    },
  },
  render: () => null,
}
```

Now we could remove the watcher in Footer.vue:
```js
  watch: {
    insaneMode() {
      window.clearInterval(this.footerIntervalId);
      window.clearInterval(this.textIntervalId);
      this.footerIntervalId = window.setInterval(this.randomFooterStyle, this.footerIntervalMs);
      this.textIntervalId = window.setInterval(this.randomTextStyle, this.textIntervalMs);
    },
  },
```

The final Footer.vue looks like this:

```html
<template>
  <footer :style="footerStyle">
    <Interval :delay="footerIntervalMs" @tick="randomFooterStyle"></Interval>
    <Interval :delay="textIntervalMs" @tick="randomTextStyle"></Interval>
    <div class="text" :style="textStyle">Made with ❤ by Jason Yu &copy; 2019</div>
    <label class="insane-mode-label">
      <input type="checkbox" v-model="insaneMode"> Insane Mode (new!)
    </label>
  </footer>
</template>

<script>
import { randomNumber, randomPercentage, randomColor } from '../services/random';
import Interval from './renderless/Interval';

const FOOTER_INTERVAL_MS = 543;
const TEXT_INTERVAL_MS = FOOTER_INTERVAL_MS / 3;

export default {
  mounted() {
    this.randomFooterStyle();
    this.randomTextStyle();
  },
  data: () => ({
    footerStyle: null,
    textStyle: null,
    insaneMode: false,
  }),
  computed: {
    insaneFactor() {
      return this.insaneMode ? 3 : 1;
    },
    footerIntervalMs() {
      return FOOTER_INTERVAL_MS / this.insaneFactor;
    },
    textIntervalMs() {
      return FOOTER_INTERVAL_MS / this.insaneFactor;
    },
  },
  methods: {
    randomFooterStyle() {
      const { insaneFactor } = this;
      this.footerStyle = {
        borderRadius: `${randomPercentage()} ${randomPercentage()} / ${randomPercentage()} ${randomPercentage()}`,
        background: randomColor(),
        transitionDuration: `${FOOTER_INTERVAL_MS / insaneFactor}ms`,
      };
    },
    randomTextStyle() {
      const { insaneFactor } = this;
      this.textStyle = {
        transform: `rotate(${randomNumber(
          -3 * insaneFactor,
          3 * insaneFactor,
        )}deg) scale(${randomNumber(0.7 * insaneFactor, 1.3 * insaneFactor)})`,
        color: randomColor(),
        transitionDuration: `${TEXT_INTERVAL_MS / insaneFactor}ms`,
      };
    },
  },
};
</script>

<style scoped>
footer {
  margin-top: 1rem;
  padding: 3rem 0;
  transition-property: border-radius, background;
  text-align: center;
}
footer .text {
  transition-property: color, transform;
}
.insane-mode-label {
  display: block;
  margin-top: 2rem;
}
</style>
```

## Challenge for you!

I hope you find this article interesting. If you wish to learn more about different types of renderless components, please watch the [video](https://www.youtube.com/watch?v=j_WU0xx_O58) of the talk that I gave with more live-coding examples.

There are still two lines in the `mounted` hook in Footer.vue. Could you think of a way to extend `<Interval>` so that we could eliminate the whole of the `mounted` hook? Peek the [PR]([https://github.com/ycmjason-talks/2019-06-20-vue-js-london-meetup-11/pull/2](https://github.com/ycmjason-talks/2019-06-20-vue-js-london-meetup-11/pull/2)) here to get ideas.

```js
  mounted() {
    this.randomFooterStyle();
    this.randomTextStyle();
  },
```

## Why?

We build really cool product at [Attest](https://www.askattest.com/) with Vue. And we find this pattern beneficial in many ways, e.g. maintainability, correctness, testability etc. If you would like to join this exceptionally talented team, [apply here today](https://jobs.lever.co/attest/d8c2fa28-5fbd-4e6a-b759-bf1f1e07cfde)!

P.S. We love the function-based RFC.