YCM Jason

Vue without View - An Introduction to Renderless Components

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 of the My Amazing Website. (Do not peak into PR yet if you don't want spoilers.)

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:

<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:

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:

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:

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:

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!

Let's handle the setInterval and clearInterval in the mounted and beforeDestroy hooks respectively in Footer.vue:

// ...
  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:

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

The resulting Footer.vue will look like:

<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:

<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

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:

  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:

<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 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 here to get ideas.

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

# Why?

We build really cool product at Attest 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!

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